Turning a Stripe Subscription Into a Bot-Buyable API | DialtoneApp

8 min read Original article ↗

Build Log

DialtoneApp | April 23, 2026

DialtoneApp already sold a $9.00 monthly membership to humans through /login, /settings, and Stripe Checkout. I wanted a different bot to buy the same offer without ever seeing a raw card number. That turned out to be less of an MCP problem and more of a payment-authority problem.

The useful result is one honest endpoint: POST /api/v1/commerce/membership-intents. The same endpoint now does three visible things. It returns 402 Payment Required when the buyer has no machine-payment token. It returns another 402 when the token is valid but the owner has not saved a /bot-buyer card. It returns 202 Accepted when Stripe creates the subscription. Owner verification still happens after payment.

That build taught me more about bot-to-bot selling than any survey could. The interesting work is not “how do I show a buy button in chat.” The interesting work is: who authorized the spend, on what offer, for how much, through which merchant contract, and what should the seller do after the money moves.

Claim

This is the whole article in miniature. The same membership endpoint moves through three states as the buyer gets closer to real spending authority.

1

Unsigned request

POST /api/v1/commerce/membership-intents
owner_email=owner@example.com

HTTP 402 Payment Required
payment-required: <base64 challenge>
provider: dialtoneapp_network
accepts:
  scheme: dialtoneapp-network:stripe-subscription
  amount: $9.00
  currency: USD
  interval: month

The seller publishes a machine-readable price and a machine-readable payment challenge instead of jumping straight to browser checkout.

2

Signed request, but no saved card

POST /api/v1/commerce/membership-intents
payment-signature: dtapp_network_v1....

HTTP 402 Payment Required
reason: dialtoneapp_network_payment_failed
message: No saved DialtoneApp Network card is available
setup_url: /bot-buyer

A valid token is not enough. The owner still needs to have delegated a real payment method to this machine flow.

3

Signed request after card setup

POST /api/v1/commerce/membership-intents
payment-signature: dtapp_network_v1....

HTTP 202 Accepted
status: payment_accepted_owner_verification_required
payment.status: settled
settlement.status: stripe_subscription_created
next_action: verify_owner_for_paid_membership

The bot can pay once the owner has already delegated a real card and a real policy. Even then, payment is not the same thing as fulfillment.

I started with a boring human checkout

The pre-bot version of DialtoneApp already worked for humans. A website owner could go to /login, use email OTP or Google login, open /settings, and buy the membership through Stripe. That path was fine for a person in a browser and bad for a buyer bot.

human_flow:
  /login
  -> email_otp | google_oauth
  -> /settings
  -> Stripe checkout
  -> active membership

I kept the first bot-buying test narrow on purpose. The buyer was andrewarrow.dev. The agent ran from a Cloudflare Cron job. The catalog had exactly one allowed seller: DialtoneApp itself. I was not trying to solve open-web discovery. I was trying to make one real SaaS purchase honest and inspectable.

  • I used one seller and one offer: the DialtoneApp $9.00/month membership.
  • The buyer was a concrete site, `andrewarrow.dev`, not a general shopping bot.
  • The buyer used a trusted one-item catalog. I intentionally skipped open-web discovery.
  • A bot never saw a raw card number. The owner had to save a card in `/bot-buyer` first.
  • Owner verification still happens after payment, so this is not “one header and now everything is autonomous.”

I made discovery boring and payment explicit

The first step was not checkout. It was a machine-readable offer. I published the membership in the obvious places a buyer would actually inspect: the commerce manifest, the UCP companion, the public offer endpoint, and the OpenAPI contract.

GET /.well-known/commerce

{
  "offers": [
    {
      "id": "membership-monthly",
      "price": { "amount": "9.00", "currency": "USD", "interval": "month" },
      "required_inputs": ["owner_email"],
      "optional_inputs": ["website_domain"],
      "purchase_modes": [
        "api_offer_lookup",
        "api_purchase_intent_bootstrap",
        "dialtoneapp_network_payment",
        "browser_checkout_fallback"
      ]
    }
  ]
}

The canonical runtime stayed plain HTTPS. That was deliberate. MCP and A2A are useful adapters, but I did not want the first working purchase path to depend on one runtime choice. Every buyer can call HTTPS. The contract lives in the API.

The second step was the 402 loop. Nevermined was useful here because it gave me the right mental model: if the request is payable, return a challenge and let the caller retry with proof. DialtoneApp now does the same thing for its own saved-card path.

curl -i -X POST https://dialtoneapp.com/api/v1/commerce/membership-intents   -H 'Content-Type: application/json'   -d '{"owner_email":"owner@example.com","machine_id":"local-curl"}'

HTTP/1.1 402 Payment Required
payment-required: <base64 payment challenge>

{
  "provider": "dialtoneapp_network",
  "payment_required": {
    "accepts": [
      {
        "scheme": "dialtoneapp-network:stripe-subscription",
        "network": "stripe:sandbox",
        "amount": "9.00",
        "currency": "USD",
        "interval": "month"
      }
    ]
  }
}

A valid token still is not payment authority

This was the part that made the project click for me. A buyer can hold a valid payment token and still not be allowed to spend. The seller has to know which human account the spend belongs to and whether that account already delegated a usable payment method.

DialtoneApp Network signature claims:
{
  "aud": "/api/v1/commerce/membership-intents",
  "offer_id": "membership-monthly",
  "owner_email": "owner@example.com",
  "website_domain": "andrewarrow.dev",
  "machine_id": "local-curl",
  "max_amount_cents": 900,
  "currency": "USD",
  "exp": "...",
  "jti": "..."
}

The worker verifies those claims and then looks up the real payment authority. That lookup ends in user_network_payment_methods. If the owner has not logged in and saved a card through Stripe SetupIntent in /bot-buyer, the machine purchase still fails.

HTTP/1.1 402 Payment Required

{
  "provider": "dialtoneapp_network",
  "reason": "dialtoneapp_network_payment_failed",
  "message": "No saved DialtoneApp Network card is available for this user. Sign in at /bot-buyer, add a card, and retry the machine purchase.",
  "setup_url": "https://dialtoneapp.com/bot-buyer"
}

That was the real design goal: the bot never sees a raw card number. The owner signs in, saves a card once, and delegates a bounded right to spend against that card for this offer. The bot only carries a scoped proof that it may ask.

When the saved card exists, the worker either reuses an existing matching Stripe subscription or creates a new one off-session. The response is still honest about what happened next.

HTTP/1.1 202 Accepted
payment-response: <base64 receipt>

{
    "status": "payment_accepted_owner_verification_required",
    "payment": {
      "provider": "dialtoneapp_network",
    "status": "settled",
    "settlement": {
      "status": "stripe_subscription_created",
      "stripe_payment_method_source": "saved_network_payment_method"
    }
  },
  "next_actions": [
    {
      "id": "verify_owner_for_paid_membership",
      "url": "https://dialtoneapp.com/login?email=owner%40example.com"
    }
  ]
}

The whole world of bot-to-bot selling, in one stack

This is the compact version I wish I had at the start. People talk past each other in this space because they mix runtime, commerce semantics, payment authority, and fulfillment into one blurry concept called “agentic commerce.”

LayerQuestionThis buildBroader world
RuntimeHow does the buyer call the seller?One HTTPS endpoint: POST /api/v1/commerce/membership-intents.APIs, MCP, and A2A live here. They are invocation choices, not payment authority.
Commerce semanticsWhat is being sold, and how does the buyer discover it?/.well-known/commerce, /.well-known/ucp, /api/v1/commerce/membership-offer, and /openapi.json.This is the UCP or ACP layer: offers, required inputs, checkout states, and purchase intent endpoints.
Payment authorityWhy is the bot allowed to spend?payment-signature, a saved /bot-buyer card, and claim checks on owner_email, website_domain, machine_id, and amount.This is the hard part. AP2, shared payment tokens, and local saved-card authority all try to solve this layer.
Fulfillment and controlWhat happens after money moves?The success state is still payment_accepted_owner_verification_required.Merchant control, receipts, account binding, fallback checkout, and post-purchase state still matter after payment.

Question

How does the buyer call the seller?

This build

One HTTPS endpoint: POST /api/v1/commerce/membership-intents.

Broader world

APIs, MCP, and A2A live here. They are invocation choices, not payment authority.

Question

What is being sold, and how does the buyer discover it?

This build

/.well-known/commerce, /.well-known/ucp, /api/v1/commerce/membership-offer, and /openapi.json.

Broader world

This is the UCP or ACP layer: offers, required inputs, checkout states, and purchase intent endpoints.

Question

Why is the bot allowed to spend?

This build

payment-signature, a saved /bot-buyer card, and claim checks on owner_email, website_domain, machine_id, and amount.

Broader world

This is the hard part. AP2, shared payment tokens, and local saved-card authority all try to solve this layer.

Question

What happens after money moves?

This build

The success state is still payment_accepted_owner_verification_required.

Broader world

Merchant control, receipts, account binding, fallback checkout, and post-purchase state still matter after payment.

That is why broad explainer posts often feel vague. A concrete build makes the boundaries visible. In this one project alone, I needed ordinary HTTPS, a machine-readable offer, a challenge-and-retry payment loop, scoped spend, saved-card authority, Stripe settlement, owner verification, receipts, and browser fallback.

runtime        != commerce semantics
commerce      != payment authority
payment       != fulfillment
browser fallback is still part of the system

Appendix: raw traces and limits

These traces came from the same implementation window on April 22, 2026. They are the smallest pieces of evidence I would want to inspect if someone else claimed this system worked.

Signature checks

verify payment-signature:
  aud must equal /api/v1/commerce/membership-intents
  offer_id must equal membership-monthly
  exp must be in the future
  owner_email must match
  website_domain must match when supplied
  machine_id must match when supplied
  max_amount_cents must authorize 900

Saved-card lookup

resolve payment authority:
  find user by owner_email
  find payment method in user_network_payment_methods
  if no payment method:
    return 402 with setup_url /bot-buyer

Stripe settlement

if reusable subscription exists:
  status = existing_subscription_reused
else:
  create Stripe subscription off-session
  status = stripe_subscription_created

Still missing

not solved here:
  open-web seller discovery
  generalized merchant network acceptance
  delegated refunds and disputes
  cross-merchant standard payment authority
  zero-human post-payment onboarding