Building a Dual-Stack Agent Wallet: Stripe Link + OpenWallet.sh

Working code: a Node.js agent that routes payments to Stripe Link for off-chain and OpenWallet.sh for on-chain. Includes the policy file, the audit log, and t

Does the OWS daemon need to be online for the agent to sign?
Yes. The daemon is the only thing with access to the decrypted key. If the daemon is down, the agent’s on-chain payments fail. Run the daemon as a systemd service with auto-restart.

Building an AI agent that can pay for things is a 2026 must-have. Whether you’re using Stripe’s Link wallet for agents, OpenWallet.sh, or both, the implementation is straightforward. This post walks through a working dual-stack agent that books a hotel via Link, settles the invoice in USDC via OWS, and logs every spend for audit.

The reference architecture

Our agent has three layers:

  1. Decision layer — picks which wallet (Link or OWS) for a given invoice based on the payment rail.
  2. Approval layer — for Link, sends a push notification; for OWS, checks the policy file.
  3. Execution layer — for Link, calls the Stripe API; for OWS, calls the local OWS daemon.

All three layers log to a central audit trail. The user has full visibility into every spend.

Project setup

mkdir agent-pay && cd agent-pay
npm init -y
npm install stripe @ows/client express dotenv

Environment variables

# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
OWS_DAEMON_URL=http://127.0.0.1:8421
OWS_WALLET=agent-treasury
AGENT_AUDIT_LOG=./audit.log

OWS policy file

Place this at ~/.ows/policies/agent-treasury.yaml:

wallet: agent-treasury
chain: eip155:8453   # Base

# Per-transaction limits
max_value_per_tx: 1000000000000000000   # 1 ETH in wei
max_value_per_day: 10000000000000000000  # 10 ETH per day

# Allowlist (EVM addresses)
allowlist:
  - 0xHotelHotels.eth
  - 0xUber.eth
  - 0xAmazon.eth
  - 0xOpenAI.eth

# Blocklist (always denied)
blocklist:
  - 0xKnownScammer.eth

# Per-chain rate limits
rate_limits:
  eip155:8453: 100 tx/day
  solana:5eykt4UsFv6PuuQMwQDRQE7w3b: 50 tx/day

Payment router (the core)

// lib/pay.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const ows = require('@ows/client');
const fs = require('fs');

const AUDIT = process.env.AGENT_AUDIT_LOG;

function audit(entry) {
  const line = JSON.stringify({
    timestamp: new Date().toISOString(),
    ...entry,
  });
  fs.appendFileSync(AUDIT, line + 'n');
}

async function pay(invoice) {
  // Decide which rail
  if (['card', 'ach', 'usdc-spt'].includes(invoice.rail)) {
    return await payViaLink(invoice);
  } else if (['usdc', 'eth', 'sol', 'btc'].includes(invoice.rail)) {
    return await payViaOWS(invoice);
  } else {
    throw new Error(`Unknown rail: ${invoice.rail}`);
  }
}

async function payViaLink(invoice) {
  // Step 1: Create the spend request
  const spend = await stripe.wallet.spends.create({
    amount: Math.round(invoice.amount * 100),  // cents
    currency: invoice.currency.toLowerCase(),
    merchant_domain: invoice.merchant,
    category: invoice.category,
    description: invoice.description,
    idempotency_key: invoice.idempotencyKey,
  });

  audit({
    type: 'link_spend_created',
    spend_id: spend.id,
    amount: invoice.amount,
    merchant: invoice.merchant,
    status: spend.status,
  });

  // Step 2: Wait for user approval (poll or webhook)
  const approved = await waitForApproval(spend.id, 60_000);
  if (!approved) {
    audit({ type: 'link_spend_declined', spend_id: spend.id });
    return { status: 'declined' };
  }

  // Step 3: Hand the virtual card to the merchant
  // (In a real implementation, this would call the merchant's API
  //  or fill a checkout form with the card details)
  await provisionToMerchant(spend.virtual_card, invoice);

  audit({ type: 'link_spend_completed', spend_id: spend.id });
  return { status: 'completed', spend_id: spend.id };
}

async function payViaOWS(invoice) {
  // The OWS daemon enforces the policy; we just call sign()
  const signature = await ows.sign({
    wallet: process.env.OWS_WALLET,
    chain: invoice.chain,
    to: invoice.to,
    value: invoice.value,        // wei / lamports / satoshi
    data: invoice.data || '0x',
    memo: invoice.description,
  });

  audit({
    type: 'ows_sign_completed',
    chain: invoice.chain,
    to: invoice.to,
    value: invoice.value,
    signature: signature,
  });

  return { status: 'signed', signature };
}

module.exports = { pay };

Webhook handler for Link approval

// server.js
const express = require('express');
const { pay } = require('./lib/pay');
require('dotenv').config();

const app = express();
app.use(express.raw({ type: 'application/json' }));

app.post('/stripe/webhook', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'link.wallet.spend.approved') {
    // Find the original spend and provision the card
    const spend = event.data.object;
    await provisionApprovedSpend(spend);
  } else if (event.type === 'link.wallet.spend.declined') {
    audit({ type: 'link_spend_declined_webhook', spend_id: event.data.object.id });
  }

  res.json({ received: true });
});

app.post('/agent/pay', async (req, res) => {
  const { invoice } = req.body;
  try {
    const result = await pay(invoice);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(3000, () => console.log('Agent pay on :3000'));

The full flow: book a hotel

// In the agent's main loop
const { pay } = require('./lib/pay');

async function bookHotel({ hotel, checkIn, checkOut, rooms }) {
  // Step 1: Get the invoice from the hotel's API
  const invoice = await fetch(`https://api.hilton.com/bookings/${hotel}/quote`, {
    method: 'POST',
    body: JSON.stringify({ checkIn, checkOut, rooms }),
  }).then(r => r.json());

  // Step 2: Pay the invoice — Link will route to card, OWS will route to USDC
  const result = await pay({
    rail: invoice.payment_rail,    // 'card' or 'usdc'
    amount: invoice.total,
    currency: invoice.currency,
    merchant: invoice.merchant_domain,
    category: 'lodging',
    description: `Hotel ${hotel}, ${checkIn} - ${checkOut}`,
    idempotencyKey: `hotel-${hotel}-${checkIn}`,

    // OWS-only fields (ignored if rail is 'card')
    chain: 'eip155:8453',         // Base
    to: invoice.crypto_address,
    value: invoice.crypto_amount_wei,
  });

  return result;
}

Audit log format

Every spend lands in audit.log as a JSON line:

{"timestamp":"2026-04-29T18:32:01Z","type":"link_spend_created","spend_id":"link_spend_abc123","amount":300,"merchant":"hilton.com","status":"pending_review"}
{"timestamp":"2026-04-29T18:32:47Z","type":"link_spend_completed","spend_id":"link_spend_abc123"}
{"timestamp":"2026-04-29T19:14:22Z","type":"ows_sign_completed","chain":"eip155:8453","to":"0xHotelHotels.eth","value":"300000000000000000","signature":"0x9f3a..."}

You can grep, pipe to jq, load into a dashboard, or ship to a SIEM. The audit log is the source of truth for “what did my agent spend and where.”

Testing the dual stack

Three test environments to set up before going live:

  1. Stripe test mode — use sk_test_... keys. The agent’s spend requests go to a sandbox Link account. Approval notifications hit the test mobile app.
  2. OWS local testnet — point the OWS daemon at Base Sepolia. Test sign flows with throwaway test ETH.
  3. End-to-end happy path — a single agent task that exercises both rails. Verify the audit log captures every event.

What’s next

This is the foundation. Once the dual stack is working, the next layer is agent-to-agent payments: your agent pays another agent (e.g., a research agent on Bittensor, a data agent on Ocean Protocol) for a service. Both Link and OWS support this — Link via card-on-card, OWS via on-chain native. The protocol details differ, but the audit pattern is identical.

FAQ

What if the user denies a Link spend?
The virtual card is voided, the merchant never gets a usable card number, and the agent’s request returns declined. The agent should handle this gracefully (try a different hotel, suggest an alternative).

What if OWS policy denies a spend?
The OWS daemon returns policy_violation with the specific rule that failed. The agent should adjust (lower amount, different destination) or surface the error to the user.

Can I batch multiple payments in one Link call?
Not in v1. Each spend is one API call. If you need to pay 10 invoices, you make 10 calls and the user gets 10 notifications (or 1 per allowlisted merchant, depending on your policy).

Does the OWS daemon need to be online for the agent to sign?
Yes. The daemon is the only thing with access to the decrypted key. If the daemon is down, the agent’s on-chain payments fail. Run the daemon as a systemd service with auto-restart.

Can I run OWS on a separate machine and connect remotely?
Not recommended. The OWS daemon binds to localhost by default for security. If you need remote signing, use a hardware security module (HSM) or a TEE-based signer like lit.protocol. Don’t expose the OWS daemon to the network.