Inspiration

We were talking about the importance and the future of OpenClaw (the fastest-growing open source repo ever). We'd all heard about the challenges and vulnerabilities that come with giving agents access to the web and to personal details, especially when it comes to money. There was no trusted, scoped, auditable payment primitive for agents. We understood how huge the potential was, not least because of Gartner's forecast that 40% of enterprise apps will have embedded AI agents by 2026. It made the timing obvious, and we built the trust layer. Monzo's hackathon track and Stripe's challenge aligned perfectly with that gap.

What it does

It is a trusted payment infrastructure for AI agents.

Scenario Example:

  1. A user tells an AI agent to "Buy me AirPods Pro."
  2. The agent (powered by OpenClaw) searches the web for real pages, real prices.

3. It finds AirPods Pro at apple.com for €279

  1. It shares all the information and asks: "Approve?"
  2. The user taps Approve in Telegram.
  3. A Stripe virtual card flashes into existence: limit €279 exactly, merchant category electronics only, expiry 15 minutes. The card is connected to the user's Monzo bank account, assigned to a specific Pot.
  4. The agent checks out. Order confirmed.

8. The card is canceled. Your real card was never touched.

The full lifecycle from approval to receipt to card death takes under a minute. Your money is exposed for exactly that long, to exactly one merchant, for exactly what you said yes to.

How we built it

TranzAct is a Node.js + TypeScript backend that acts as a trusted financial intermediary between AI agents and real payment infrastructure.

Architecture: A strict state machine drives every purchase intent. Transitions are atomic (Postgres + AuditEvent); illegal moves throw.

  • Main path: RECEIVED → SEARCHING → QUOTED → AWAITING_APPROVAL → APPROVED → CARD_ISSUED → CHECKOUT_RUNNING → DONE
  • Branches: AWAITING_APPROVAL → DENIED · CHECKOUT_RUNNING → FAILED · any active state → EXPIRED

Flow: Agent creates intent → search job runs → user gets Telegram approve/reject → on approval we reserve funds (ledger pot), issue a Stripe virtual card with spending controls, and enqueue checkout → agent polls for decision and gets card details once → agent posts result; pot settles or returns, card is cancelled.

Modules: contracts (shared types) · orchestrator (state machine) · payments (Stripe card lifecycle, one-time reveal) · policy (budget, allowlists, rate limit) · ledger (pot reserve/settle/return) · approval (idempotent decision) · queue + worker (BullMQ, stub OpenClaw) · telegram (signup, notifications) · api (routes, validators, idempotency, worker auth).

Onboarding: Agent gets a pairing code via POST /v1/agent/register; user sends /start <code> in Telegram and enters email; account is linked. Agent resolves userId via GET /v1/agent/user.

Challenges we ran into

1. The one-time card reveal constraint. Stripe's Issuing API only exposes PAN and CVC via server-side calls with expand: ['number', 'cvc'] — and we needed to guarantee it was surfaced to the agent exactly once and never stored. We solved this with a revealedAt timestamp on the VirtualCard row: the first call marks it, subsequent calls throw CardAlreadyRevealedError and return a clean 409. The tricky edge case was the brief APPROVED → CARD_ISSUED transition window, where the agent polling loop could race the card issuance — we handled that state explicitly in the polling endpoint.

2. Atomic state transitions with side effects. The approval flow has several steps: record decision → reserve ledger funds → issue Stripe card → advance state machine → enqueue checkout job. Any one of those can fail. We wrapped the Prisma writes in transactions, and added explicit rollback logic (e.g. if card issuance fails after fund reservation, returnIntent is called immediately to avoid funds being locked forever).

3. Stripe cardholder upsert. Stripe Issuing requires a Cardholder object per card, but you can't keep creating new cardholders for the same user. We built a upsert: if user.stripeCardholderId is set, reuse it; otherwise create a new cardholder and persist the ID.

4. Telegram webhook vs. polling, and stateful multi-step signup. The bot needed to track state across two messages (code → email) without a session store framework. We built a lightweight Redis-backed session store keyed by Telegram chatId.

5. Integration testing against live Stripe test mode. Unit tests mock Stripe, but we needed integration tests to verify the full card lifecycle. We wrote a checkoutSimulator that creates a real Stripe PaymentIntent against a test card number, triggering the Issuing authorization flow on Stripe's test infrastructure — making the integration suite actually meaningful rather than just another mock.

Accomplishments that we're proud of

  • A complete, working payment authorization system built in under 30 hours — from schema to API to Telegram bot to Stripe card lifecycle, all integrated and tested
  • 25 test files covering every module: unit tests with full mocking, integration tests against real Postgres + Redis, and E2E tests against Stripe test mode
  • Zero card data stored in the database. The VirtualCard table only stores stripeCardId and last4. PAN and CVC live in Stripe's vault and are fetched exactly once over TLS at reveal time
  • A genuine state machine with enforced transitions, an immutable audit trail on every intent, and idempotency on approval decisions — this isn't a demo that pretends to be robust, it actually is
  • A clean module boundary system — every module owns its files, all cross-module calls go through typed interfaces in src/contracts/, and the modules were built in parallel by separate agents without stepping on each other
  • The pairing code onboarding flow — a user-friendly UX where the agent generates a short alphanumeric code, the user enters it in Telegram, and within seconds they're linked and receiving approval notifications

What we learned

  • Spending controls on Stripe Issuing are more powerful than we expected. Per-card MCC category allowlists and single-use spending limits mean the card itself enforces the policy even if our backend has a bug — a genuine defense-in-depth layer
  • State machines pay for themselves immediately. Early on we considered just updating a status column directly. Using a formal transition table with event-driven semantics meant bugs surfaced as explicit IllegalTransitionErrors rather than silent bad state
  • Idempotency is non-negotiable for financial APIs. We learned quickly that approval decisions in particular needed idempotency keys — a double-tap on "Approve" in Telegram should not issue two cards or reserve funds twice
  • Redis sessions for Telegram multi-step flows are deceptively tricky. The bot's stateful signup (code → email → account creation) looked simple but required careful cleanup (clearing sessions after success or reuse) to avoid users getting stuck in a half-created state
  • Integration tests against real external APIs are worth the setup cost. The Stripe integration tests caught a real bug (zero-balance user signup) that unit tests with mocks had missed entirely

What's next for TranzAct

  • Expiry enforcement — the expiresAt field and EXPIRED terminal state are modelled, but a background job to sweep and expire stale intents is not yet running
  • Production hardening — user-facing routes currently have no auth; adding JWT-based user authentication and PII tokenization (email, phone) are the next security priorities
  • Multi-currency support — the ledger and card issuance support the currency field throughout, but cross-currency settlement logic (e.g. reserve in GBP, charge in EUR) needs explicit handling
  • Webhooks from Stripe Issuing — the /v1/webhooks/stripe route exists and verifies signatures, but the handler for issuing_authorization.request events (real-time authorization decisions) is stubbed; wiring this would allow synchronous authorization decisions rather than relying solely on pre-set spending controls
  • Merchant reputation scoring — the policy engine today checks allowlists; a future layer would query a merchant trust score before even sending the approval request to the user

Built With

Share this project:

Updates