Skip to Content
RecipesRecurring Payments

Recurring Payments Agent with WaaP CLI

What are we cooking?

An autonomous Node.js agent that executes scheduled on-chain payments using the WaaP CLI. The agent reads a JSON config of payment schedules, checks which payments are due on each tick, and submits transactions — all without ever exposing a raw private key.

Managing recurring payments on-chain is painful. There are no standing orders or direct debits in crypto — every transfer requires an explicit signed transaction. This agent automates that, turning a JSON config file into a reliable payment scheduler.

Use cases:

  • Subscription payments — recurring SaaS or service fees paid in stablecoins
  • Payroll — monthly salary disbursements to team members
  • DAO contributor payments — regular compensation for ongoing contributors
  • Recurring donations — weekly or monthly contributions to public goods like Protocol Guild

The agent will:

  1. Load a payment config defining recipients, amounts, tokens, and intervals.
  2. On each tick (default: every 60 seconds), check which payments are due based on the last payment timestamp.
  3. Submit due payments on-chain via waap-cli send-tx — native ETH or ERC-20 tokens.
  4. Persist payment history to a local JSON file so it resumes correctly after restarts without double-paying.

Key Components

  • WaaP CLI — Signs and broadcasts on-chain transactions through a 2PC-MPC enclave. No raw private key in your .env.
  • Payment config — A JSON file defining each scheduled payment: recipient, token, amount, interval, and label.
  • Payment history — A local JSON file tracking every payment attempt and the last-paid timestamp per schedule.
  • Base network — The default target chain (configurable per payment).

Prerequisites

Before starting, you need:

  • A WaaP wallet — created via waap-cli signup
  • ETH on Base — for gas fees
  • Tokens for payments — ETH for native transfers, or ERC-20 tokens (e.g., USDC) for token transfers
  • Recipient addresses — the wallet addresses you will be paying

Project Setup

mkdir waap-recurring-payments && cd waap-recurring-payments npm init -y npm install dotenv execa npm install -g @human.tech/waap-cli@latest

Set Up a WaaP Wallet for Your Agent

# Create a dedicated agent account waap-cli signup --email youremail+recurring-payments@example.com --password '12345678!' # Or log in to an existing one waap-cli login --email youremail+recurring-payments@example.com --password '12345678!' # Get the wallet address — fund it with ETH and any ERC-20 tokens you plan to send waap-cli whoami

Environment Variables

Create a .env file (never commit this to source control):

# Required PAYMENT_CONFIG_PATH=./payments.json # Path to your payment schedule JSON # Optional — sensible defaults provided DEFAULT_CHAIN_ID=8453 # Base chain ID (default) AGENT_POLL_INTERVAL_MS=60000 # 60 seconds between due-checks PAYMENT_HISTORY_PATH=./data/payment-history.json # Where payment history is persisted AGENT_LOG_FILE=./logs/recurring-payments.jsonl # Structured log output AGENT_DRY_RUN=1 # Set to '0' to go live — '1' = dry run

The Payment Config

Create a payments.json file with an array of payment definitions:

[ { "recipient": "0xAbC123...", "tokenAddress": "native", "amount": "0.01", "decimals": 18, "intervalMs": 2592000000, "label": "Monthly server costs" }, { "recipient": "0xDeF456...", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "3000", "decimals": 6, "intervalMs": 2592000000, "label": "Monthly salary - Alice" }, { "recipient": "0x789Abc...", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "50", "decimals": 6, "intervalMs": 604800000, "label": "Weekly donation - Protocol Guild" } ]

Field Reference

FieldRequiredDescription
recipientYesDestination wallet address
tokenAddressNoERC-20 contract address, or "native" for ETH. Defaults to "native"
amountYesHuman-readable amount (e.g., "100.5" for 100.5 USDC)
decimalsNoToken decimals — 18 for ETH, 6 for USDC. Defaults to 18
intervalMsYesMilliseconds between payments
labelYesUnique human-readable label (used as the key for tracking payment history)
chainIdNoPer-payment chain override. Defaults to DEFAULT_CHAIN_ID env var or 8453 (Base)
enabledNoSet to false to pause a payment without removing it. Defaults to true

Common Intervals

IntervalMilliseconds
Hourly3600000
Daily86400000
Weekly604800000
Biweekly1209600000
Monthly (30 days)2592000000

The Recipe Workflow

1. Loading and Validating Payment Config

The agent reads the JSON config at startup and validates that every entry has the required fields:

import * as fs from 'fs'; import * as path from 'path'; const CONFIG_PATH = path.resolve(process.env.PAYMENT_CONFIG_PATH ?? './payments.json'); function loadPaymentConfig() { if (!fs.existsSync(CONFIG_PATH)) { console.error('Payment config not found:', CONFIG_PATH); process.exit(1); } const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); const configs = Array.isArray(raw) ? raw : raw.payments; for (const c of configs) { if (!c.recipient || !c.amount || !c.intervalMs || !c.label) { console.error('Invalid payment config — missing required fields:', c.label ?? 'unknown'); process.exit(1); } // Apply defaults if (c.tokenAddress === undefined) c.tokenAddress = 'native'; if (c.decimals === undefined) c.decimals = 18; if (c.enabled === undefined) c.enabled = true; } return configs; }

2. Converting Human-Readable Amounts to Wei

Token amounts in the config are human-readable (e.g., "100.5" USDC). The agent converts them to the smallest unit (wei) using the token’s decimal count:

function toWei(amount, decimals) { const parts = amount.split('.'); const whole = parts[0] ?? '0'; let fraction = parts[1] ?? ''; if (fraction.length > decimals) { fraction = fraction.slice(0, decimals); } fraction = fraction.padEnd(decimals, '0'); return BigInt(whole + fraction); } // Examples: // toWei("0.01", 18) => 10000000000000000n (0.01 ETH) // toWei("3000", 6) => 3000000000n (3000 USDC) // toWei("100.5", 6) => 100500000n (100.5 USDC)

3. Sending Native ETH Payments

For payments where tokenAddress is "native", the agent sends ETH directly using waap-cli send-tx with the --value flag:

import { execa } from 'execa'; async function sendNativePayment(recipient, amount, decimals, chainId) { const amountWei = toWei(amount, decimals); const { stdout } = await execa('waap-cli', [ 'send-tx', '--chain-id', String(chainId), '--to', recipient, '--value', amountWei.toString(), '--json', ]); // Parse the transaction hash from WaaP CLI output const lines = stdout.split(/\r?\n/).filter((l) => l.trim().startsWith('{')); for (const line of lines) { try { const obj = JSON.parse(line); if (obj.txHash) return { txHash: obj.txHash }; } catch {} } throw new Error('Could not parse transaction hash from waap-cli output'); }

4. Sending ERC-20 Token Payments

For ERC-20 payments (e.g., USDC, DAI), the agent encodes a transfer(address,uint256) call and sends it as contract calldata. No ethers.js or viem dependency required — the encoding is straightforward:

// ERC-20 transfer function selector: 0xa9059cbb function encodeErc20Transfer(recipient, amountWei) { const selector = 'a9059cbb'; const addressPadded = recipient.toLowerCase().replace('0x', '').padStart(64, '0'); const amountHex = amountWei.toString(16).padStart(64, '0'); return `0x${selector}${addressPadded}${amountHex}`; } async function sendErc20Payment(tokenAddress, recipient, amount, decimals, chainId) { const amountWei = toWei(amount, decimals); const calldata = encodeErc20Transfer(recipient, amountWei); const { stdout } = await execa('waap-cli', [ 'send-tx', '--chain-id', String(chainId), '--to', tokenAddress, // The ERC-20 contract, not the recipient '--data', calldata, // Encoded transfer(recipient, amount) '--json', ]); // Parse transaction hash (same as native payment parsing) const lines = stdout.split(/\r?\n/).filter((l) => l.trim().startsWith('{')); for (const line of lines) { try { const obj = JSON.parse(line); if (obj.txHash) return { txHash: obj.txHash }; } catch {} } throw new Error('Could not parse transaction hash from waap-cli output'); }

Note the key difference from native payments: the --to flag points at the token contract address (e.g., USDC on Base: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913), and the actual recipient is encoded inside the --data calldata.

5. Payment History Tracking

The agent persists a payment-history.json file that tracks two things:

  • A lastPaidMap keyed by payment label, storing the ISO timestamp of the last successful payment. This determines when each payment is next due.
  • A payments array of every payment attempt (sent or failed), with transaction hash, timestamp, and status.
const HISTORY_PATH = path.resolve( process.env.PAYMENT_HISTORY_PATH ?? './data/payment-history.json' ); function loadHistory() { if (fs.existsSync(HISTORY_PATH)) { try { return JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf-8')); } catch { console.warn('Corrupt history file — starting fresh'); } } return { payments: [], lastPaidMap: {} }; } function saveHistory(history) { const dir = path.dirname(HISTORY_PATH); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(HISTORY_PATH, JSON.stringify(history, null, 2)); }

The history file looks like this after a few payment cycles:

{ "lastPaidMap": { "Monthly salary - Alice": "2026-05-01T00:01:12.345Z", "Weekly donation - Protocol Guild": "2026-05-08T00:01:14.567Z" }, "payments": [ { "label": "Monthly salary - Alice", "recipient": "0xDeF456...", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "3000", "txHash": "0xabc123...", "chainId": 8453, "paidAt": "2026-05-01T00:01:12.345Z", "status": "sent" } ] }

This means the agent resumes correctly after restarts — it will not double-pay for a period that was already covered.

6. Checking Which Payments Are Due

On each tick, the agent iterates over every payment config and checks whether enough time has passed since the last successful payment:

function isDue(payment, history) { if (payment.enabled === false) return false; const lastPaidIso = history.lastPaidMap[payment.label]; if (!lastPaidIso) return true; // Never paid before — due immediately const lastPaidMs = new Date(lastPaidIso).getTime(); return Date.now() >= lastPaidMs + payment.intervalMs; }

7. Putting It All Together — The Poll Loop

import 'dotenv/config'; const CHAIN_ID = Number(process.env.DEFAULT_CHAIN_ID ?? 8453); const POLL_MS = Number(process.env.AGENT_POLL_INTERVAL_MS ?? 60_000); const DRY_RUN = process.env.AGENT_DRY_RUN === '1'; async function getWalletAddress() { const { stdout } = await execa('waap-cli', ['whoami', '--json']); const lines = stdout.split(/\r?\n/).filter((l) => l.trim().startsWith('{')); for (const line of lines) { try { const obj = JSON.parse(line); if (obj.evmWalletAddress) return obj.evmWalletAddress; } catch {} } throw new Error('No EVM wallet address found. Run `waap-cli signup` first.'); } async function tick(address, configs) { const history = loadHistory(); for (const payment of configs) { if (!isDue(payment, history)) continue; const chainId = payment.chainId ?? CHAIN_ID; const isNative = payment.tokenAddress === 'native'; console.log(`Payment due: ${payment.label} — ${payment.amount} ${isNative ? 'ETH' : payment.tokenAddress} to ${payment.recipient}`); if (DRY_RUN) { console.log(`[DRY_RUN] Skipping: ${payment.label}`); continue; } const record = { label: payment.label, recipient: payment.recipient, tokenAddress: payment.tokenAddress, amount: payment.amount, txHash: null, chainId, paidAt: new Date().toISOString(), status: 'sent', }; try { let result; if (isNative) { result = await sendNativePayment(payment.recipient, payment.amount, payment.decimals, chainId); } else { result = await sendErc20Payment(payment.tokenAddress, payment.recipient, payment.amount, payment.decimals, chainId); } record.txHash = result.txHash; console.log(`Payment sent: ${payment.label} — tx: ${result.txHash}`); // Update last-paid timestamp only on success history.lastPaidMap[payment.label] = record.paidAt; } catch (err) { record.status = 'failed'; record.error = err instanceof Error ? err.message : String(err); console.error(`Payment failed: ${payment.label} — ${record.error}`); } history.payments.push(record); saveHistory(history); } } async function main() { const configs = loadPaymentConfig(); console.log(`Loaded ${configs.length} payment schedule(s): ${configs.map((c) => c.label).join(', ')}`); const address = await getWalletAddress(); console.log(`Agent wallet: ${address}`); console.log(`Chain: ${CHAIN_ID}, poll interval: ${POLL_MS}ms, dry run: ${DRY_RUN}`); while (true) { try { await tick(address, configs); } catch (err) { console.error('Tick failed:', err.message); } await new Promise((r) => setTimeout(r, POLL_MS)); } } main().catch((err) => { console.error('Fatal:', err); process.exit(1); });

Run the agent:

# Start in dry-run mode first (default) AGENT_DRY_RUN=1 node agent.js # Go live — submit real transactions AGENT_DRY_RUN=0 node agent.js

Safety: Privileges and Spend Limits

WaaP supports Privileges — pre-approved spending scopes that restrict what the agent can do on-chain. For a recurring payments agent, Privileges are essential: they ensure the agent can only pay the addresses and amounts you have approved.

# Only allow transactions to specific recipient addresses and token contracts waap-cli policy set --allowed-contracts 0xDeF456...,0x789Abc...,0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 # Set a daily spend limit to cap total outflows waap-cli policy set --daily-spend-limit 5000 # Require human approval for any single transaction above a threshold waap-cli policy set --approval-threshold 10000

These constraints mean:

  • The agent can only send to the recipient addresses and token contracts in the approved list.
  • Total daily spend is capped — even if the config file is tampered with.
  • Large one-off payments require manual approval via the WaaP dashboard.

Dry-Run Mode

Always start with AGENT_DRY_RUN=1. In dry-run mode, the agent logs which payments would be sent but does not submit any transactions. This lets you verify the schedule, amounts, and recipients before going live.

# Verify the agent sees the right payments and timing AGENT_DRY_RUN=1 node agent.js # Sample dry-run output: # Payment due: Monthly salary - Alice — 3000 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 to 0xDeF456... # [DRY_RUN] Skipping: Monthly salary - Alice

Next Steps

Now that your agent can execute scheduled payments:

  • Add balance monitoring: Check the wallet balance before each payment and alert (or pause) if funds are running low.
  • Multi-chain support: Use the per-payment chainId field to pay recipients on different chains (Base, Ethereum mainnet, Arbitrum) from the same agent.
  • Notification integration: Send Telegram or Slack alerts when payments succeed, fail, or when the wallet balance drops below a threshold.
  • Config hot-reload: Watch payments.json for changes so you can add or modify payment schedules without restarting the agent.
  • Connect the AEX dashboard: The Agent Exchange dashboard  can ingest the agent’s JSONL logs for a live view of payment history and status.
Last updated on