SOPSellers' Open Protocol

Machine payments (MPP and x402, testnet)

Let an agent skip the checkout page and pay for a booking through an HTTP 402 payment link, using MPP or x402.

TestnetTestnet only: only testnet tokens are accepted, and no real money moves.

This page is for the engineers who build the integration. Merchants don't need to read it.

What machine payments are

Machine payments let an agent pay for an API or service directly, with no checkout page: the server replies with HTTP 402 and the payment requirements, the agent pays and retries with proof of payment, and gets the result and a receipt. MPP (Machine Payments Protocol) and x402 are two ways to do it, and Stripe supports stablecoin payments over both. SOP uses it for bookings: each booking awaiting payment comes with a payment link that an agent can pay with MPP or x402.

Payment flow

Machine payments: pay a SOP payment link with MPP or x402
AgentSOPSellerPOST the payment linkwithout payment402 Payment Requiredwith MPP and x402challengesPays on the networkwith its walletRetries with thepayment credentialVerifies the payment200 with a receipt;booking marked paidNotifies the paidbooking
Steps
  1. Agent → SOP: POST the payment link without payment
  2. SOP → Agent: 402 Payment Required with MPP and x402 challenges
  3. Agent → Agent: Pays on the network with its wallet
  4. Agent → SOP: Retries with the payment credential
  5. SOP → SOP: Verifies the payment
  6. SOP → Agent: 200 with a receipt; booking marked paid
  7. SOP → Seller: Notifies the paid booking

A booking awaiting payment includes a payment_link in its response: the URL, the supported protocols (x402, mpp), the amount in US dollars, the network, and testnet: true. The URL looks like /pay/{business_id}/{booking_id}?t=…; the code in it can only be used to pay, not to view, change or cancel the booking. Opened in a browser, it shows a page explaining that this is a payment link for AI agents.

A request sent without payment gets a 402 response carrying challenges for both protocols: the PAYMENT-REQUIRED header for x402, and the WWW-Authenticate: Payment header for MPP.

Machine payment: the 402 challenge (x402 and MPP, testnet)

curl

# <PAYMENT_LINK> is booking.payment_link.url from an awaiting_payment booking (testnet only).
LINK="<PAYMENT_LINK>"
curl -s -D headers.txt -o /dev/null -X POST "$LINK" -H "User-Agent: my-agent/1.0"
head -n 1 headers.txt
grep -i '^www-authenticate:' headers.txt
grep -i '^payment-required:' headers.txt | cut -d' ' -f2 | tr -d '\r' | base64 -d; echo

Python

import base64
import json
import urllib.error
import urllib.request

LINK = "<PAYMENT_LINK>"  # booking.payment_link.url from an awaiting_payment booking (testnet only)

req = urllib.request.Request(LINK, method="POST", headers={"User-Agent": "my-agent/1.0"})
try:
    urllib.request.urlopen(req)
except urllib.error.HTTPError as err:  # the 402 challenge arrives as an HTTPError
    print("HTTP", err.code)
    print("WWW-Authenticate:", err.headers["WWW-Authenticate"])  # MPP challenges (evm and tempo)
    required = json.loads(base64.b64decode(err.headers["PAYMENT-REQUIRED"]))  # x402 requirements
    print(json.dumps(required, indent=2))

Node.js

const LINK = "<PAYMENT_LINK>"; // booking.payment_link.url from an awaiting_payment booking (testnet only)

(async () => {
  const res = await fetch(LINK, { method: "POST", headers: { "User-Agent": "my-agent/1.0" } });
  console.log("HTTP", res.status);
  console.log("WWW-Authenticate:", res.headers.get("www-authenticate")); // MPP challenges (evm and tempo)
  const required = JSON.parse(Buffer.from(res.headers.get("payment-required"), "base64").toString()); // x402 requirements
  console.log(JSON.stringify(required, null, 2));
})();

Example response

HTTP 402
WWW-Authenticate: Payment id="vRToYQ7TnWS1YWLEM_jL3lsSSJukGrQY_fqB9yVqs9g", realm="mcp.srct.tw", method="evm", intent="charge", request="…", description="… booking 88QBMCY5 (Example Studio (test data))", expires="2026-10-21T17:02:58.846Z", opaque="eyJib29raW5nX2lkIjoiODhRQk1DWTUiLCJidXNpbmVzc19pZCI6ImV4YW1wbGUtc3R1ZGlvIn0",
  Payment id="dqLCoVLnoZGkwD_-4UG_pr2KORDUXFIh1-IShyvfdKA", realm="mcp.srct.tw", method="tempo", intent="charge", request="…", description="… booking 88QBMCY5 (Example Studio (test data))", expires="2026-10-21T17:02:58.846Z", opaque="eyJib29raW5nX2lkIjoiODhRQk1DWTUiLCJidXNpbmVzc19pZCI6ImV4YW1wbGUtc3R1ZGlvIn0"
{
  "accepts": [
    {
      "amount": "18530000",
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "extra": {
        "assetTransferMethod": "eip3009",
        "name": "USDC",
        "version": "2"
      },
      "maxTimeoutSeconds": 300,
      "network": "eip155:84532",
      "payTo": "0x3b0b371ae3cB08F272b87319684f6518b3b313a0",
      "scheme": "exact"
    }
  ],
  "error": "Payment is required (… booking 88QBMCY5 (Example Studio (test data))).",
  "extensions": {
    "mppx": {
      "info": {
        "method": "POST",
        "opaque": "eyJib29raW5nX2lkIjoiODhRQk1DWTUiLCJidXNpbmVzc19pZCI6ImV4YW1wbGUtc3R1ZGlvIn0"
      },
      "schema": {
        "additionalProperties": false,
        "properties": {
          "_mppx_scope": {
            "type": "string"
          },
          "digest": {
            "type": "string"
          },
          "method": {
            "type": "string"
          },
          "nonce": {
            "type": "string"
          },
          "opaque": {
            "type": "string"
          }
        },
        "required": [
          "method"
        ],
        "type": "object"
      }
    }
  },
  "resource": {
    "url": "https://mcp.srct.tw/pay/example-studio/88QBMCY5?t=<PAY_TOKEN>"
  },
  "x402Version": 2
}

Run against a local copy of the service: Local copy of the connector (real Worker runtime), testnet only. request="…" is a long base64 value left out; PAYMENT-REQUIRED is shown decoded. payTo is the Stripe sandbox deposit address; the ?t= pay token is replaced by <PAY_TOKEN>.

Pay with MPP

The example below pays with pathUSD on the Tempo testnet, using the mppx command-line wallet. Once payment succeeds, the booking is marked paid (payment method mpp_tempo), becomes confirmed or waits for the merchant to confirm under its rules, and the merchant is notified.

Machine payment: pay the link with the mppx CLI (testnet)

Shell

# One-time setup (testnet wallet): npx mppx@0.13.1 account create --account my-wallet
#                                  npx mppx@0.13.1 account fund --account my-wallet
# <PAYMENT_LINK> is booking.payment_link.url from an awaiting_payment booking (testnet only).
# --currency picks pathUSD on the Tempo testnet; without it mppx may choose the Base Sepolia USDC offer instead.
npx mppx@0.13.1 "<PAYMENT_LINK>" -X POST --account my-wallet --network testnet \
  --currency 0x20c0000000000000000000000000000000000000

Python

import subprocess

LINK = "<PAYMENT_LINK>"  # booking.payment_link.url from an awaiting_payment booking (testnet only)

# mppx answers the 402 challenge with the testnet wallet and retries the request.
result = subprocess.run(
    ["npx", "mppx@0.13.1", LINK, "-X", "POST", "--account", "my-wallet", "--network", "testnet",
     "--currency", "0x20c0000000000000000000000000000000000000"],  # pathUSD on the Tempo testnet
    capture_output=True,
    text=True,
)
print(result.stdout)
print(result.stderr)

Node.js

const LINK = "<PAYMENT_LINK>"; // booking.payment_link.url from an awaiting_payment booking (testnet only)

(async () => {
  const { execFileSync } = await import("node:child_process");
  // mppx answers the 402 challenge with the testnet wallet and retries the request.
  console.log(execFileSync("npx", ["mppx@0.13.1", LINK, "-X", "POST", "--account", "my-wallet", "--network", "testnet", "--currency", "0x20c0000000000000000000000000000000000000"], { encoding: "utf8" }));
})();

Example response

{
  "ok": true,
  "booking": {
    "booking_id": "3K3T8RED",
    "status": "pending",
    "status_text": "Waiting for the business to confirm",
    "business_id": "example-studio",
    "business_name": "Example Studio (test data)",
    "service_id": "small_group",
    "service_name": "Small-group strength class",
    "slot": "2026-10-25T10:00",
    "start": "2026-10-25T10:00:00+08:00",
    "label": "10/25(日)10:00",
    "quantity": 1,
    "quantity_unit": "seat",
    "payment": "Paid online: NT$600",
    "details": [],
    "payment_details": {
      "mode": "online",
      "status": "paid",
      "amount": {
        "value": 60000,
        "currency": "twd",
        "text": "NT$600"
      },
      "collected_by": "Seller Tech U.S.A. LLC through Stripe",
      "paid_at": "2026-10-22T00:49:00+08:00",
      "method": "mpp_tempo",
      "reference": "0x60373d2fc9f6547ab9817f6bbe911689094159ea8dc072235b644dcf468a91c6"
    },
    "why_pending": "First booking with this business from these contact details, so the business confirms it.",
    "confirm_by": "2026-10-22T00:59:00+08:00",
    "hold": "Bookings that need the business's confirmation are held for 10 minutes. If the business does not confirm by then, the hold is released automatically and the customer can choose another slot.",
    "online_cancel_until": "2026-10-25T08:00:00+08:00"
  },
  "payment": {
    "status": "succeeded",
    "method": "mpp_tempo",
    "reference": "0x60373d2fc9f6547ab9817f6bbe911689094159ea8dc072235b644dcf468a91c6",
    "amount_usd": "18.53",
    "recorded_in_stripe": false
  }
}

Run against a local copy of the service: Local copy of the connector, Tempo testnet, a test wallet that holds test tokens only. The connector checks the payment on-chain; the booking becomes paid (payment_details.method mpp_tempo, reference = transaction hash). recorded_in_stripe is false because this local copy has no Stripe key.

Networks and status

ProtocolNetwork and currencyStatus
MPPTempo testnet (Moderato), pathUSDPaid successfully with a test wallet in a local environment; the payment link on the live URL has not been tested yet
x402Base Sepolia, USDCServer-side verification works; there is no successful payment on record yet (the test wallet has no test USDC)

Testnet payment links are open only to test businesses and never appear for real businesses, so there is currently no booking available for outside parties to try.

Amount and refunds

Discovery document

/openapi.json marks payable operations with x-payment-info. Each booking has a different amount, so the amount field is null.

Read the discovery document: /openapi.json

curl

curl -sS "https://mcp.srct.tw/openapi.json" \
  -H "User-Agent: my-agent/1.0"

Python

import json
import urllib.error
import urllib.request

BASE = "https://mcp.srct.tw"


def call(method, path, body=None, headers=None):
    req = urllib.request.Request(
        BASE + path,
        data=None if body is None else json.dumps(body).encode(),
        method=method,
        headers={"User-Agent": "my-agent/1.0", "Content-Type": "application/json", **(headers or {})},
    )
    try:
        with urllib.request.urlopen(req) as res:
            return res.status, json.loads(res.read())
    except urllib.error.HTTPError as err:  # 4xx and 5xx responses still carry a JSON body
        return err.code, json.loads(err.read())


def show(status, body):
    print("HTTP", status)
    print(json.dumps(body, ensure_ascii=False, indent=2))


show(*call("GET", "/openapi.json"))

Node.js

const BASE = "https://mcp.srct.tw";

async function call(method, path, body, headers = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: { "User-Agent": "my-agent/1.0", "Content-Type": "application/json", ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  return { status: res.status, body: await res.json() };
}

function show({ status, body }) {
  console.log("HTTP", status);
  console.log(JSON.stringify(body, null, 2));
}

(async () => {
  show(await call("GET", "/openapi.json"));
})();

Example response

HTTP 200
{
  "openapi": "3.1.0",
  "info": {
    "title": "… booking payments",
    "version": "1.0.0",
    "description": "Pay for … bookings with x402 or MPP. Create the booking first with the MCP tool request_booking or POST /api/v1/businesses/{business_id}/bookings; use the payment_link it returns."
  },
  "servers": [
    {
      "url": "https://mcp.srct.tw"
    }
  ],
  "x-service-info": {
    "categories": [
      "booking",
      "fitness",
      "services"
    ],
    "docs": {
      "homepage": "https://mcp.srct.tw/",
      "apiReference": "https://mcp.srct.tw/docs"
    }
  },
  "paths": {
    "/pay/{business_id}/{booking_id}": {
      "post": {
        "summary": "Pay for a booking (x402 or MPP)",
        "parameters": [
          {
            "name": "business_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "booking_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "t",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "pay token from the booking's payment_link"
          }
        ],
        "x-payment-info": {
          "offers": [
            {
              "intent": "charge",
              "method": "evm",
              "amount": null,
              "currency": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
              "description": "USDC on Base Sepolia (test network); also accepted as x402 exact"
            },
            {
              "intent": "charge",
              "method": "tempo",
              "amount": null,
              "currency": "0x20c0000000000000000000000000000000000000",
              "description": "pathUSD on Tempo test network (Moderato)"
            }
          ]
        },
        "responses": {
          "200": {
            "description": "Paid: booking and payment result"
          },
          "402": {
            "description": "Payment Required: x402 PAYMENT-REQUIRED and MPP WWW-Authenticate challenges"
          },
          "409": {
            "description": "Booking not payable"
          },
          "410": {
            "description": "Payment window passed"
          }
        }
      },
      "get": {
        "summary": "Pay for a booking (x402 or MPP)",
        "parameters": [
          {
            "name": "business_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "booking_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "t",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "pay token from the booking's payment_link"
          }
        ],
        "x-payment-info": {
          "offers": [
            {
              "intent": "charge",
              "method": "evm",
              "amount": null,
              "currency": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
              "description": "USDC on Base Sepolia (test network); also accepted as x402 exact"
            },
            {
              "intent": "charge",
              "method": "tempo",
              "amount": null,
              "currency": "0x20c0000000000000000000000000000000000000",
              "description": "pathUSD on Tempo test network (Moderato)"
            }
          ]
        },
        "responses": {
          "200": {
            "description": "Paid: booking and payment result"
          },
          "402": {
            "description": "Payment Required: x402 PAYMENT-REQUIRED and MPP WWW-Authenticate challenges"
          },
          "409": {
            "description": "Booking not payable"
          },
          "410": {
            "description": "Payment window passed"
          }
        }
      }
    }
  }
}

Run against the live service (read-only)

What is not there yet