SOPSellers' Open Protocol

替客人付款

當預約需要線上付款,代理取得客人核准的付款憑證,呼叫complete_booking完成。

已上線預約時的線上付款已上線;第一家商家(一位私人教練)已於2026-10-22啟用線上付款。

這頁是給開發代理的工程師看的。商家想了解付款怎麼進來,請看接受代理預約。

付款流程

商家設為線上付款時,request_booking不會直接讓預約成立,而是回傳awaiting_payment:先佔住名額(預設15分鐘),並附上金額、幣別、期限與payment_handlers。代理請客人核准確切金額,取得付款憑證後呼叫complete_booking。

代理:用客人核准的憑證付款給SOP
客人代理SOPrequest_bookingpayment_handlers:com.stripe.payments與business profile;金額;期限請客人核准確切金額核准發出綁定該profile的共享付款憑證,限金額與期限帶憑證呼叫complete_booking已付款(已成立或待商家確認),或requires_action附3D Secure連結告知完成,或請客人完成3D Secure
步驟
  1. 代理 → SOP:request_booking
  2. SOP → 代理:payment_handlers:com.stripe.payments與business profile;金額;期限
  3. 代理 → 客人:請客人核准確切金額
  4. 客人 → 代理:核准
  5. 代理 → 代理:發出綁定該profile的共享付款憑證,限金額與期限
  6. 代理 → SOP:帶憑證呼叫complete_booking
  7. SOP → 代理:已付款(已成立或待商家確認),或requires_action附3D Secure連結
  8. 代理 → 客人:告知完成,或請客人完成3D Secure

1. 建立需要付款的預約

建立預約的呼叫和不需付款時相同。回傳的status是awaiting_payment,並帶amount(新臺幣最小單位,NT$1,200記為120000)、currency、expires_at與payment_handlers。價格為0的服務一律不收款。

建立線上付款的預約

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

回應範例

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"
}

已對本機服務實際執行:本機的連接器副本,使用合成的測試商家與佔位的Stripe profile(profile_xxxx),所以environment是testmode。建立預約這一步不會呼叫Stripe。status_token與?t=付款代碼已換成佔位字串。

payment_handlers是UCP格式的com.stripe.payments:id為stripe_payments,可用的付款工具有card與link,config.business_profile是代理發出憑證時要指定的Stripe profile。

2. 取得付款憑證

代理用客人核准的付款方式,向Stripe發出一個Shared Payment Token(SPT),收款方指定為上一步的business_profile,並限定幣別、最高金額(等於這筆預約的總額)與到期時間。這一步由代理自己的Stripe帳戶執行,不經過SOP。

代理端:向商家發出Shared Payment Token

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

未由我們實際執行;依服務商文件撰寫:未實際執行:需要代理自己的Stripe金鑰,我們沒有。參數名稱與Stripe-Version標頭照Stripe針對代理端的Shared Payment Token文件撰寫。

SPT是Stripe的preview功能,API版本與參數以Stripe文件為準;適用地區與條款見付款憑證。

3. 完成預約

呼叫complete_booking,帶business_id、booking_id、status_token與payment:handler_id填stripe_payments,instrument_type為card或link,credential為{"type": "stripe_payment_token", "token": "spt_…"}。

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

回應範例

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"
  }
}

回應取自服務的自動測試:取自連接器的測試環境:連接器程式碼原樣執行,Stripe由測試套件的假Stripe伺服器取代,所以transaction_id(pi_1)與金流手續費(USD1.20,換算NT$39)都是測試值。沒有任何實際扣款。

回應的payment.status有以下幾種;付款被拒絕時則回錯誤payment_declined(REST為402),並標示可重試:

狀態意思代理該做什麼
succeeded已付款。預約成立(confirmed),或依商家的確認規則先等商家確認(pending)。告訴客人結果。
requires_action需要客人完成驗證,例如3D Secure。見下一節。
processingStripe還在處理。稍後用get_booking_status查詢。
payment_declined(錯誤)付款被拒絕,預約回到awaiting_payment。請客人換一個付款方式,在期限內用新的憑證重新呼叫。

需要驗證時(3D Secure)

銀行要求3D Secure時,payment.status為requires_action,並附redirect_url。請客人在這個網址完成驗證,代理再用同一個憑證呼叫一次complete_booking;伺服器會查原本那筆付款,不會重複扣款。

3D Secure:付款回傳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.

回應範例

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."
}

回應取自服務的自動測試:取自連接器的測試環境,Stripe由測試套件的假伺服器取代。redirect_url是假伺服器的佔位網址;實際會是Stripe的網址。客人完成驗證、你再送一次請求之前,預約維持awaiting_payment。

查詢預約與付款狀態

get_booking_status(REST為GET bookings/{booking_id})回傳預約狀態與付款、退款資訊。付款還在處理或等驗證時,會先向Stripe同步一次。

查詢預約狀態

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

回應範例

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"
  }
}

已對本機服務實際執行:本機的連接器副本。請求中的status_token請換成建立預約時取得的值。

安全重試

寫入類工具可帶idempotency_key(8至64個字元,可用英數字與_ . : -),REST則用Idempotency-Key標頭。同一個鍵加同樣的內容,會回傳第一次的結果;同一個鍵但內容不同,回idempotency_conflict(409)。紀錄保留48小時。另外,同一筆預約加同一個憑證,只會扣款一次。

用冪等鍵安全重送請求

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

回應範例

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."
  }
}

已對本機服務實際執行:為顯示而截短;…表示省略的部分。本機的連接器副本。第2個回應是先前儲存的原回應(同一筆預約、同一個status_token)再加上"replayed": true;重複的部分以…表示。

逾時與退款

流程圖與退款回應見接受代理預約。

憑證被撤銷或過期時

代理可以在Shared Payment Token被使用前隨時撤銷它。complete_booking收到已撤銷、已過期或已用過的憑證時,會回傳token_invalid,不會扣款;請客人重新核准,再發一個新憑證。如果預約本身的expires_at已經過了,名額已經釋出。

狀態更新

SOP不會對代理發送webhook。要追蹤預約,請帶status_token呼叫get_booking_status(或用REST GET該筆預約)。卡片付款的結果,直接在complete_booking的回應裡。

測試你的串接

正式服務上的卡片付款會付給真實商家,請不要用來測試。需要測試時,請聯絡我們安排測試用的商家。