SOPSellers' Open Protocol

Pay for a customer

When a booking needs online payment, the agent gets a payment credential the customer approved and calls complete_booking.

LiveOnline payment when booking is live; the first merchant, a personal trainer, turned on online payment on 2026-10-22.

This page is for engineers building agents. Merchants who want to know how payment reaches them should read Accept agent bookings.

Payment flow

When a merchant has turned on online payment, request_booking does not confirm the booking right away. It returns awaiting_payment: the place is held (15 minutes by default) and the response includes the amount, currency, expiry and payment_handlers. The agent asks the customer to approve the exact amount, gets a payment credential, and calls complete_booking.

Agents: pay SOP with a credential the customer approved
CustomerAgentSOPrequest_bookingpayment_handlers:com.stripe.paymentsand business profile;amount; expires_atAsks to approve theexact amountApprovesIssues a SharedPayment Token forthat profile, limitedto the amount andexpirycomplete_booking withthe tokenPaid (confirmed, orpending thebusiness), orrequires_action witha 3D Secure linkConfirms, or opensthe 3D Secure link
Steps
  1. Agent → SOP: request_booking
  2. SOP → Agent: payment_handlers: com.stripe.payments and business profile; amount; expires_at
  3. Agent → Customer: Asks to approve the exact amount
  4. Customer → Agent: Approves
  5. Agent → Agent: Issues a Shared Payment Token for that profile, limited to the amount and expiry
  6. Agent → SOP: complete_booking with the token
  7. SOP → Agent: Paid (confirmed, or pending the business), or requires_action with a 3D Secure link
  8. Agent → Customer: Confirms, or opens the 3D Secure link

1. Create a booking that needs payment

Creating the booking is the same call as for a booking without payment. The returned status is awaiting_payment, with amount (in the smallest unit of New Taiwan dollars: NT$1,200 is 120000), currency, expires_at and payment_handlers. A service priced at 0 is never charged.

Create a booking that is paid online

curl

# <BUSINESS_ID> is a business whose payment mode is online (none is enabled yet). The booking is held until expires_at.
curl -sS -X POST "https://mcp.srct.tw/api/v1/businesses/<BUSINESS_ID>/bookings" \
  -H "User-Agent: my-agent/1.0" \
  -H "Content-Type: application/json" \
  -d '{"service_id":"small_group","slot":"<SLOT>","quantity":1,"customer_name":"Example Customer","phone":"0900000000","email":"customer@example.com"}'

Python

# <BUSINESS_ID> is a business whose payment mode is online (none is enabled yet). The booking is held until expires_at.
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))


BUSINESS_ID = "<BUSINESS_ID>"

status, body = call("POST", f"/api/v1/businesses/{BUSINESS_ID}/bookings", {
    "service_id": "small_group",
    "slot": "<SLOT>",  # the "slot" value from the availability response
    "quantity": 1,
    "customer_name": "Example Customer",
    "phone": "0900000000",
    "email": "customer@example.com",
})
show(status, body)
# status "awaiting_payment": body["booking"] has amount, currency, expires_at and payment_handlers.

Node.js

// <BUSINESS_ID> is a business whose payment mode is online (none is enabled yet). The booking is held until expires_at.
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));
}

const BUSINESS_ID = "<BUSINESS_ID>";

(async () => {
  show(await call("POST", `/api/v1/businesses/${BUSINESS_ID}/bookings`, {
    service_id: "small_group",
    slot: "<SLOT>", // the "slot" value from the availability response
    quantity: 1,
    customer_name: "Example Customer",
    phone: "0900000000",
    email: "customer@example.com",
  }));
  // status "awaiting_payment": body.booking has amount, currency, expires_at and payment_handlers.
})();

Example response

HTTP 201
{
  "ok": true,
  "booking": {
    "booking_id": "MCK3JUPZ",
    "status": "awaiting_payment",
    "status_text": "Waiting for online payment",
    "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": "Online payment required: NT$600",
    "details": [],
    "payment_details": {
      "mode": "online",
      "status": "awaiting_payment",
      "amount": {
        "value": 60000,
        "currency": "twd",
        "text": "NT$600"
      },
      "collected_by": "Seller Tech U.S.A. LLC through Stripe",
      "expires_at": "2026-10-22T01:02:00+08:00"
    },
    "amount": 60000,
    "currency": "twd",
    "expires_at": "2026-10-22T01:02:00+08:00",
    "payment_handlers": [
      {
        "id": "stripe_payments",
        "type": "com.stripe.payments",
        "version": "2026-06-25",
        "spec": "https://docs.stripe.com/agentic-commerce/ucp/stripe-payments-handler",
        "schema": "https://ucp.stripe.com/payments/2026-06-25/schema.json",
        "available_instruments": [
          {
            "type": "card"
          },
          {
            "type": "link",
            "config": {
              "network_id": "profile_xxxx"
            }
          }
        ],
        "config": {
          "environment": "testmode",
          "business_profile": "profile_xxxx"
        }
      }
    ],
    "payment_link": {
      "url": "https://mcp.srct.tw/pay/example-studio/MCK3JUPZ?t=<PAY_TOKEN>",
      "protocols": [
        "x402",
        "mpp"
      ],
      "amount_usd": "18.53",
      "fx": {
        "twd_per_usd": 32.385,
        "source": "Bank of Taiwan board rate, USD spot buying",
        "as_of": "2026-10-21T23:42:00+08:00"
      },
      "networks": [
        "base-sepolia (USDC, x402 or MPP evm)",
        "tempo testnet (MPP tempo)"
      ],
      "testnet": true
    },
    "online_cancel_until": "2026-10-25T08:00:00+08:00"
  },
  "status_token": "<STATUS_TOKEN>",
  "status_token_note": "Needed with booking_id to check, pay, change or cancel this booking later. It gives access to this booking only, and … does not show it again.",
  "next_step": "Get the customer's approval for NT$600 and a payment credential from one of payment_handlers (for com.stripe.payments: a Stripe shared payment token issued to business_profile, limited to this amount and currency), then call complete_booking before expires_at. Unpaid bookings are released at expires_at.",
  "contact_note": "If this booking cannot be cancelled with cancel_booking, the customer can contact the business directly: Test data, no real contact"
}

Run against a local copy of the service: Local copy of the connector with a synthetic test business and a placeholder Stripe profile (profile_xxxx), so environment is testmode. Creating the booking makes no call to Stripe. status_token and the ?t= pay token are replaced by placeholders.

payment_handlers follows the UCP payment handler format with com.stripe.payments: the id is stripe_payments, the available instruments are card and link, and config.business_profile is the Stripe profile the agent must name when it issues the credential.

2. Get a payment credential

Using the payment method the customer approved, the agent issues a Shared Payment Token (SPT) through Stripe. The recipient is the business_profile from the previous step, and the token is limited in currency, maximum amount (equal to the booking total) and expiry. This step runs in the agent's own Stripe account and does not go through SOP.

Agent: issue a Shared Payment Token to the business

curl

# Run by the agent with its own Stripe key. profile_..., amount and the other values come from the awaiting_payment booking.
curl https://api.stripe.com/v1/shared_payment/issued_tokens \
  -u "sk_test_...:" \
  -H "User-Agent: my-agent/1.0" \
  -H "Stripe-Version: 2026-09-30.preview" \
  -d "payment_method=pm_..." \
  -d "seller_details[network_business_profile]=profile_..." \
  -d "usage_limits[currency]=twd" \
  -d "usage_limits[expires_at]=1798761600" \
  -d "usage_limits[max_amount]=60000" \
  --data-urlencode "return_url=https://example.com/agent-checkout/return"

Python

import base64
import json
import urllib.parse
import urllib.request

SECRET_KEY = "sk_test_..."  # the agent's own Stripe key

form = urllib.parse.urlencode({
    "payment_method": "pm_...",
    "seller_details[network_business_profile]": "profile_...",  # payment_handlers[0].config.business_profile
    "usage_limits[currency]": "twd",                            # booking.currency
    "usage_limits[expires_at]": "1798761600",                   # Unix time, later than your complete_booking call
    "usage_limits[max_amount]": "60000",                        # booking.amount
    "return_url": "https://example.com/agent-checkout/return",
}).encode()

req = urllib.request.Request(
    "https://api.stripe.com/v1/shared_payment/issued_tokens",
    data=form,
    method="POST",
    headers={
        "Authorization": "Basic " + base64.b64encode(f"{SECRET_KEY}:".encode()).decode(),
        "Stripe-Version": "2026-09-30.preview",
        "User-Agent": "my-agent/1.0",
    },
)
with urllib.request.urlopen(req) as res:
    print(json.dumps(json.loads(res.read()), indent=2))  # the "id" (spt_...) is what you send to complete_booking

Node.js

const SECRET_KEY = "sk_test_..."; // the agent's own Stripe key

(async () => {
  const res = await fetch("https://api.stripe.com/v1/shared_payment/issued_tokens", {
    method: "POST",
    headers: {
      Authorization: "Basic " + Buffer.from(`${SECRET_KEY}:`).toString("base64"),
      "Stripe-Version": "2026-09-30.preview",
      "User-Agent": "my-agent/1.0",
    },
    body: new URLSearchParams({
      payment_method: "pm_...",
      "seller_details[network_business_profile]": "profile_...", // payment_handlers[0].config.business_profile
      "usage_limits[currency]": "twd", // booking.currency
      "usage_limits[expires_at]": "1798761600", // Unix time, later than your complete_booking call
      "usage_limits[max_amount]": "60000", // booking.amount
      return_url: "https://example.com/agent-checkout/return",
    }),
  });
  console.log(JSON.stringify(await res.json(), null, 2)); // the "id" (spt_...) is what you send to complete_booking
})();

Not run by us; follows the provider's documentation: Not run: it needs the agent's own Stripe secret key, which we do not have. Parameter names and the Stripe-Version header follow Stripe's shared payment tokens documentation for agents.

SPT is a Stripe preview feature, so the API version and parameters follow Stripe's documentation; for supported regions and terms see Payment credentials.

3. Complete the booking

Call complete_booking with business_id, booking_id, status_token and payment: handler_id is stripe_payments, instrument_type is card or link, and credential is {"type": "stripe_payment_token", "token": "spt_…"}.

Pay for a booking with a Shared Payment Token

curl

# Reuse the same Idempotency-Key when you retry this request after a network error.
curl -sS -X POST "https://mcp.srct.tw/api/v1/businesses/<BUSINESS_ID>/bookings/<BOOKING_ID>/complete" \
  -H "User-Agent: my-agent/1.0" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <STATUS_TOKEN>" \
  -H "Idempotency-Key: pay-$(uuidgen)" \
  -d '{"payment":{"instruments":[{"id":"instr_1","handler_id":"stripe_payments","type":"card","selected":true,"credential":{"type":"stripe_payment_token","token":"spt_..."}}]}}'

Python

import json
import urllib.error
import urllib.request
import uuid

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))


BUSINESS_ID = "<BUSINESS_ID>"
BOOKING_ID = "<BOOKING_ID>"      # from the create response
STATUS_TOKEN = "<STATUS_TOKEN>"  # from the create response (shown once)

payment = {
    "instruments": [{
        "id": "instr_1",
        "handler_id": "stripe_payments",  # an id from payment_handlers
        "type": "card",
        "selected": True,
        "credential": {"type": "stripe_payment_token", "token": "spt_..."},  # the SPT the agent issued
    }]
}
headers = {
    "Authorization": f"Bearer {STATUS_TOKEN}",
    "Idempotency-Key": str(uuid.uuid4()),  # reuse the same key when you retry after a network error
}

show(*call("POST", f"/api/v1/businesses/{BUSINESS_ID}/bookings/{BOOKING_ID}/complete", {"payment": payment}, headers))

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));
}

const BUSINESS_ID = "<BUSINESS_ID>";
const BOOKING_ID = "<BOOKING_ID>"; // from the create response
const STATUS_TOKEN = "<STATUS_TOKEN>"; // from the create response (shown once)

const payment = {
  instruments: [{
    id: "instr_1",
    handler_id: "stripe_payments", // an id from payment_handlers
    type: "card",
    selected: true,
    credential: { type: "stripe_payment_token", token: "spt_..." }, // the SPT the agent issued
  }],
};
const headers = {
  Authorization: `Bearer ${STATUS_TOKEN}`,
  "Idempotency-Key": `pay-${Date.now()}`, // reuse the same key when you retry after a network error
};

(async () => {
  show(await call("POST", `/api/v1/businesses/${BUSINESS_ID}/bookings/${BOOKING_ID}/complete`, { payment }, headers));
})();

Example response

HTTP 200
{
  "ok": true,
  "booking": {
    "booking_id": "WQEMHR2E",
    "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-22T11:51:00+08:00",
      "card": {
        "brand": "visa",
        "last4": "4242"
      },
      "method": "stripe_spt",
      "transaction_id": "pi_1"
    },
    "why_pending": "First booking with this business from these contact details, so the business confirms it.",
    "confirm_by": "2026-10-22T12:01: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",
    "transaction_id": "pi_1",
    "instrument_type": "card"
  }
}

Response taken from the service's automated tests: Taken from the connector's test setup: the connector code runs unchanged, and Stripe is replaced by the test suite's fake Stripe server, so transaction_id (pi_1) and the processing fee (USD 1.20, shown as NT$39) are test values. Nothing was charged anywhere.

The response's payment.status is one of the values below; a declined payment returns the error payment_declined (402 over REST), marked as retryable:

StatusMeaningWhat the agent should do
succeededPaid. The booking is confirmed (confirmed), or waits for the merchant to confirm under its confirmation rules (pending).Tell the customer the result.
requires_actionThe customer must complete verification, such as 3D Secure.See the next section.
processingStripe is still processing.Check again later with get_booking_status.
payment_declined (error)The payment was declined and the booking returns to awaiting_payment.Ask the customer for another payment method and call again with a new credential before the expiry.

When verification is required (3D Secure)

When the bank requires 3D Secure, payment.status is requires_action and redirect_url is included. Have the customer complete verification at that URL, then call complete_booking again with the same credential; the server looks up the original payment and does not charge twice.

3D Secure: payment returns requires_action

curl

# Reuse the same Idempotency-Key when you retry this request after a network error.
# payment.status "requires_action": show payment.redirect_url to the customer. When they have finished, send this
# request again with the same token (the same Idempotency-Key works: a requires_action result is not stored).
curl -sS -X POST "https://mcp.srct.tw/api/v1/businesses/<BUSINESS_ID>/bookings/<BOOKING_ID>/complete" \
  -H "User-Agent: my-agent/1.0" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <STATUS_TOKEN>" \
  -H "Idempotency-Key: pay-$(uuidgen)" \
  -d '{"payment":{"instruments":[{"id":"instr_1","handler_id":"stripe_payments","type":"card","selected":true,"credential":{"type":"stripe_payment_token","token":"spt_..."}}]}}'

Python

import json
import urllib.error
import urllib.request
import uuid

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))


BUSINESS_ID = "<BUSINESS_ID>"
BOOKING_ID = "<BOOKING_ID>"      # from the create response
STATUS_TOKEN = "<STATUS_TOKEN>"  # from the create response (shown once)

payment = {
    "instruments": [{
        "id": "instr_1",
        "handler_id": "stripe_payments",  # an id from payment_handlers
        "type": "card",
        "selected": True,
        "credential": {"type": "stripe_payment_token", "token": "spt_..."},  # the SPT the agent issued
    }]
}
headers = {
    "Authorization": f"Bearer {STATUS_TOKEN}",
    "Idempotency-Key": str(uuid.uuid4()),  # reuse the same key when you retry after a network error
}

show(*call("POST", f"/api/v1/businesses/{BUSINESS_ID}/bookings/{BOOKING_ID}/complete", {"payment": payment}, headers))
# payment.status "requires_action": show payment.redirect_url to the customer. When they have finished,
# send this request again with the same token (the same Idempotency-Key works: a requires_action result is not stored)
# response). The booking is not charged twice.

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));
}

const BUSINESS_ID = "<BUSINESS_ID>";
const BOOKING_ID = "<BOOKING_ID>"; // from the create response
const STATUS_TOKEN = "<STATUS_TOKEN>"; // from the create response (shown once)

const payment = {
  instruments: [{
    id: "instr_1",
    handler_id: "stripe_payments", // an id from payment_handlers
    type: "card",
    selected: true,
    credential: { type: "stripe_payment_token", token: "spt_..." }, // the SPT the agent issued
  }],
};
const headers = {
  Authorization: `Bearer ${STATUS_TOKEN}`,
  "Idempotency-Key": `pay-${Date.now()}`, // reuse the same key when you retry after a network error
};

(async () => {
  show(await call("POST", `/api/v1/businesses/${BUSINESS_ID}/bookings/${BOOKING_ID}/complete`, { payment }, headers));
})();
// payment.status "requires_action": show payment.redirect_url to the customer. When they have finished,
// send this request again with the same token (the same Idempotency-Key works: a requires_action result is not stored)
// response). The booking is not charged twice.

Example response

HTTP 200
{
  "ok": true,
  "booking": {
    "booking_id": "TJXN8ZHY",
    "status": "awaiting_payment",
    "status_text": "Waiting for the customer to finish payment verification",
    "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": "Online payment required: NT$600",
    "details": [],
    "payment_details": {
      "mode": "online",
      "status": "requires_action",
      "amount": {
        "value": 60000,
        "currency": "twd",
        "text": "NT$600"
      },
      "collected_by": "Seller Tech U.S.A. LLC through Stripe",
      "expires_at": "2026-10-22T01:07:00+08:00",
      "transaction_id": "pi_7"
    },
    "amount": 60000,
    "currency": "twd",
    "expires_at": "2026-10-22T01:07:00+08:00",
    "payment_handlers": [
      {
        "id": "stripe_payments",
        "type": "com.stripe.payments",
        "version": "2026-06-25",
        "spec": "https://docs.stripe.com/agentic-commerce/ucp/stripe-payments-handler",
        "schema": "https://ucp.stripe.com/payments/2026-06-25/schema.json",
        "available_instruments": [
          {
            "type": "card"
          },
          {
            "type": "link",
            "config": {
              "network_id": "profile_xxxx"
            }
          }
        ],
        "config": {
          "environment": "testmode",
          "business_profile": "profile_xxxx"
        }
      }
    ],
    "online_cancel_until": "2026-10-25T08:00:00+08:00"
  },
  "payment": {
    "status": "requires_action",
    "transaction_id": "pi_7",
    "instrument_type": "card",
    "redirect_url": "https://hooks.stripe.test/3ds/pi_7"
  },
  "next_step": "Open redirect_url for the customer to finish payment verification, then call complete_booking again with the same credential."
}

Response taken from the service's automated tests: Taken from the connector's test setup with the test suite's fake Stripe server. redirect_url is the fake server's placeholder host; a real one is a Stripe URL. The booking stays awaiting_payment until the customer finishes verification and you send the request again.

Check booking and payment status

get_booking_status (over REST, GET bookings/{booking_id}) returns the booking status with payment and refund details. If a payment is still processing or waiting for verification, the server first syncs with Stripe.

Check a booking's status

curl

curl -sS "https://mcp.srct.tw/api/v1/businesses/<BUSINESS_ID>/bookings/<BOOKING_ID>" \
  -H "User-Agent: my-agent/1.0" \
  -H "Authorization: Bearer <STATUS_TOKEN>"

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))


BUSINESS_ID = "<BUSINESS_ID>"
BOOKING_ID = "<BOOKING_ID>"      # from the create response
STATUS_TOKEN = "<STATUS_TOKEN>"  # from the create response (shown once)
AUTH = {"Authorization": f"Bearer {STATUS_TOKEN}"}

show(*call("GET", f"/api/v1/businesses/{BUSINESS_ID}/bookings/{BOOKING_ID}", headers=AUTH))

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));
}

const BUSINESS_ID = "<BUSINESS_ID>";
const BOOKING_ID = "<BOOKING_ID>"; // from the create response
const STATUS_TOKEN = "<STATUS_TOKEN>"; // from the create response (shown once)
const AUTH = { Authorization: `Bearer ${STATUS_TOKEN}` };

(async () => {
  show(await call("GET", `/api/v1/businesses/${BUSINESS_ID}/bookings/${BOOKING_ID}`, undefined, AUTH));
})();

Example response

HTTP 200
{
  "ok": true,
  "booking": {
    "booking_id": "2QAC3NW5",
    "status": "pending",
    "status_text": "Waiting for the business to confirm",
    "business_id": "coach-dunken",
    "business_name": "Dunken教練",
    "service_id": "pt",
    "service_name": "PT一對一教練課",
    "slot": "2026-10-25T08:00",
    "start": "2026-10-25T08:00:00+08:00",
    "label": "10/25(日)08:00",
    "quantity": 1,
    "quantity_unit": "位",
    "payment": "Pay at the venue",
    "details": [
      "老師:Dunken"
    ],
    "why_pending": "First booking with this business from these contact details, so the business confirms it.",
    "confirm_by": "2026-10-22T01:00: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-24T08:00:00+08:00"
  }
}

Run against a local copy of the service: Local copy of the connector. status_token is replaced by <STATUS_TOKEN> in the code you send; the response never contains it.

Retry safely

Write tools accept idempotency_key (8 to 64 characters; letters, digits and _ . : -); over REST use the Idempotency-Key header. The same key with the same content returns the first result; the same key with different content returns idempotency_conflict (409). Records are kept for 48 hours. In addition, the same credential on the same booking is charged only once.

Retry a request safely with an idempotency key

curl

# This creates a real booking at a real business. Use a slot value from the availability response.
KEY="retry-$(uuidgen)"  # any unique string of 8-64 characters: letters, digits, _ . : -
URL=https://mcp.srct.tw/api/v1/businesses/<BUSINESS_ID>/bookings
BODY='{"service_id":"pt","slot":"<SLOT>","quantity":1,"customer_name":"Example Customer","phone":"0900000000","email":"customer@example.com"}'

# 1st call: creates the booking (201)
curl -sS -X POST "$URL" -H "User-Agent: my-agent/1.0" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d "$BODY"
echo
# 2nd call: same key, same body: the original result again, with "replayed": true (200)
curl -sS -X POST "$URL" -H "User-Agent: my-agent/1.0" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d "$BODY"
echo
# 3rd call: same key, different body: idempotency_conflict (409)
curl -sS -X POST "$URL" -H "User-Agent: my-agent/1.0" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"service_id":"pt","slot":"<SLOT>","quantity":1,"customer_name":"Example Customer","phone":"0900000000","email":"customer@example.com","note":"different"}'
echo

Python

# This creates a real booking at a real business. Use a slot value from the availability response.
import json
import urllib.error
import urllib.request
import uuid

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))


BUSINESS_ID = "<BUSINESS_ID>"
path = f"/api/v1/businesses/{BUSINESS_ID}/bookings"
booking = {
    "service_id": "pt",
    "slot": "<SLOT>",
    "quantity": 1,
    "customer_name": "Example Customer",
    "phone": "0900000000",
    "email": "customer@example.com",
}
headers = {"Idempotency-Key": str(uuid.uuid4())}  # any unique string of 8-64 characters: letters, digits, _ . : -

show(*call("POST", path, booking, headers))  # 1st call: creates the booking (201)
show(*call("POST", path, booking, headers))  # same key, same body: the original result again, "replayed": true (200)
show(*call("POST", path, {**booking, "note": "different"}, headers))  # same key, different body: idempotency_conflict (409)

Node.js

// This creates a real booking at a real business. Use a slot value from the availability response.
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));
}

const BUSINESS_ID = "<BUSINESS_ID>";
const path = `/api/v1/businesses/${BUSINESS_ID}/bookings`;
const booking = {
  service_id: "pt",
  slot: "<SLOT>",
  quantity: 1,
  customer_name: "Example Customer",
  phone: "0900000000",
  email: "customer@example.com",
};
const headers = { "Idempotency-Key": `retry-${Date.now()}` }; // any unique string of 8-64 characters: letters, digits, _ . : -

(async () => {
  show(await call("POST", path, booking, headers)); // 1st call: creates the booking (201)
  show(await call("POST", path, booking, headers)); // same key, same body: the original result again, "replayed": true (200)
  show(await call("POST", path, { ...booking, note: "different" }, headers)); // same key, different body: idempotency_conflict (409)
})();

Example response

HTTP 201
{
  "ok": true,
  "booking": {
    "booking_id": "KH6RZNWP",
    "status": "pending",
    "status_text": "Waiting for the business to confirm",
    "business_id": "coach-dunken",
    "business_name": "Dunken教練",
    "service_id": "pt",
    "service_name": "PT一對一教練課",
    "slot": "2026-10-25T08:00",
    "start": "2026-10-25T08:00:00+08:00",
    "label": "10/25(日)08:00",
    "quantity": 1,
    "quantity_unit": "位",
    "payment": "Pay at the venue",
    "details": [
      "老師:Dunken"
    ],
    "why_pending": "First booking with this business from these contact details, so the business confirms it.",
    "confirm_by": "2026-10-22T01:00: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-24T08:00:00+08:00"
  },
  "status_token": "<STATUS_TOKEN>",
  "status_token_note": "Needed with booking_id to check, pay, change or cancel this booking later. It gives access to this booking only, and … does not show it again.",
  "contact_note": "If this booking cannot be cancelled with cancel_booking, the customer can contact the business directly: …"
}

HTTP 200
{
  "ok": true,
  "booking": …,
  "status_token": "<STATUS_TOKEN>",
  "status_token_note": …,
  "contact_note": …,
  "replayed": true
}

HTTP 409
{
  "error": {
    "code": "idempotency_conflict",
    "message": "This idempotency_key was already used with different parameters. Use a new key for a different request."
  }
}

Run against a local copy of the service: Shortened for display; … marks omitted parts. Local copy of the connector. The 2nd response is the stored original response (same booking, same status_token) plus "replayed": true; its repeated parts are shown as ….

Timeouts and refunds

For the flow diagram and the refund response, see Accept agent bookings.

If the credential is revoked or expires

The agent can revoke a Shared Payment Token at any time before it is used. If complete_booking receives a revoked, expired or already-used token, it returns token_invalid and nothing is charged. Ask the customer to approve again and issue a new token. If the booking's own expires_at has passed, the slot has already been released.

Status updates

SOP does not send webhooks to agents. To follow a booking, call get_booking_status (or GET the booking over REST) with the status_token. Card payment results come back in the complete_booking response itself.

Test your integration

Card payments on the live service go to real businesses, so don't use them for testing. Contact us to test against a business set up for testing.