Agent checkout
An AI agent can book a court at a Sporty club and pay for it with a card the buyer gave the agent. There are two ways in, and they are the same checkout underneath:
- ACP: the Agentic Commerce Protocol checkout API,
version
2026-04-17, athttps://api.sporty.plus/api/acp. - MCP: an MCP server at
https://api.sporty.plus/mcp(Streamable HTTP), with tools to find clubs and free court time as well as the ACP checkout tools.
Both are described by https://api.sporty.plus/.well-known/acp.json.
Payment is a Stripe shared payment token
(spt_…). The charge is made on the club's own Stripe account; the club is the merchant of
record.
403 not_sellable for a club that has not.Who can buy
Every request carries one of our OAuth access tokens as Authorization: Bearer. What the token
is decides who the buyer is.
| Token | Scope | The buyer |
|---|---|---|
| A member's, from the authorization code flow | me.purchases.write | The member. They pay their own membership price, and the booking is theirs. |
An approved platform's client_credentials token | club.checkout.write, granted by the club | A guest, named by email. They pay what a newcomer pays. |
me.purchases.write is separate from me.reservations.write: a member who lets an application
book for them has not thereby let it spend their money.
Guest checkout is for applications Sporty has reviewed, because it creates accounts. The
club must also grant club.checkout.write to the application, in its CMS under API clients.
When the guest pays, we create a Sporty account for their email and send them a link to choose
a password.
If the email already belongs to a Sporty account, the checkout refuses with requires_sign_in.
Take the person through OAuth and buy as the member instead. Otherwise anyone could put
bookings on someone else's account by typing their address.
Finding court time
Checkout items are court slots. A slot's id names the court, the start in UTC, and the length:
slot_812_20261003T1700Z_90
Get them from the MCP tool search_availability, which lists the buyer's free starts with
what each costs them:
{
"club_id": 214,
"currency": "usd",
"timezone": "America/New_York",
"slots": [
{
"item_id": "slot_812_20261003T1700Z_90",
"court": "Court 2",
"sports": ["pickleball"],
"starts_at": "2026-10-03T13:00:00-04:00",
"ends_at": "2026-10-03T14:30:00-04:00",
"minutes": 90,
"total": 6533,
"amounts": { "price": 6000, "tax": 533, "fee": 0, "total": 6533 }
}
]
}
Find the club first with search_clubs (by name, city or address, and optionally sport).
Amounts are always integer minor units: 6533 is $65.33.
The checkout, over ACP
The endpoints are ACP's. Every request sends API-Version: 2026-04-17, and every POST sends
an Idempotency-Key.
POST /api/acp/checkout_sessions | Open a checkout for one slot. |
GET /api/acp/checkout_sessions/{id} | Read it. |
POST /api/acp/checkout_sessions/{id} | Change the slot or the buyer. |
POST /api/acp/checkout_sessions/{id}/complete | Pay and book. |
POST /api/acp/checkout_sessions/{id}/cancel | Abandon it. |
Opening or changing a checkout books nothing. It prices the slot, and the session is
ready_for_payment or not_ready_for_payment with messages saying why. Nothing is held until
the buyer pays, so a checkout nobody completes costs the club no court. A session expires 30
minutes after it was last touched.
curl -X POST https://api.sporty.plus/api/acp/checkout_sessions \
-H "Authorization: Bearer $TOKEN" \
-H 'API-Version: 2026-04-17' \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{
"line_items": [{ "id": "slot_812_20261003T1700Z_90" }],
"currency": "usd",
"capabilities": {},
"buyer": { "email": "jamie@example.com", "first_name": "Jamie", "last_name": "Rivera" }
}'
The answer is an ACP checkout session. The parts that matter here:
line_items[0]: the court, club and time, and itstotals.totals:items_base_amount,subtotal,tax,fee(a club's card surcharge, when it has one) andtotal. A US club adds sales tax on top. A club whose prices include VAT shows the VAT inside the price, astaxwith "(included)".fulfillment_options: always onedigitaloption. Nothing ships; the booking shows in the Sporty app.capabilities.payment.handlers: one handler,stripe_spt(dev.acp.tokenized.card). Itsconfig.merchant_idis the club's Stripe account, for the token's seller details.links: our terms of use and privacy policy. Show them to the buyer.
To pay, send the token:
{
"payment_data": {
"handler_id": "stripe_spt",
"instrument": { "type": "card", "credential": { "type": "spt", "token": "spt_123" } }
}
}
A successful completion answers status: "completed" with an order, whose permalink_url is
the member's bookings page on the club's site.
When it does not go through
These come back as messages on the session, not as HTTP errors, because the buyer can do
something about them:
code | What happened | Session |
|---|---|---|
payment_declined | The card was refused. Nothing was booked. | ready_for_payment: try another token. |
requires_3ds | The card asks for 3-D Secure, which agent checkout does not support yet. | ready_for_payment |
out_of_stock | Someone took the slot since it was quoted. | not_ready_for_payment: pick another slot. |
price_change (warning) | The price moved since the quote, for example because a guest's first membership is priced differently. Nothing was charged. | ready_for_payment at the new total: confirm it with the buyer. |
missing | A guest's email or name is missing. | not_ready_for_payment |
requires_sign_in | A guest's email already has a Sporty account. | not_ready_for_payment |
HTTP errors use ACP's error body, {type, code, message, param}, and are for requests the agent
got wrong: an unknown item (400 invalid_item_id), a club that does not sell through agents or
has not allowed this application (403), a session that is someone else's (404), cancelling
a completed session (405).
Idempotency
A POST repeated with the same Idempotency-Key gets the first answer back, with
Idempotent-Replayed: true, and nothing happens twice. That includes charging the card. The
same key with a different body answers 422 idempotency_conflict. While the first request is
still running, a repeat answers 409 idempotency_in_flight. Keys are remembered for 24 hours,
per application and member.
The checkout, over MCP
The MCP server authenticates with the same OAuth tokens. A client without one gets 401 with
WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp", and
finds our authorization server there. The client can register itself
and ask the member for me.purchases.write.
| Tool | |
|---|---|
search_clubs | Clubs that sell court time through agents. |
search_availability | Free starts at a club, priced for the buyer. |
create_checkout_session | ACP create. |
get_checkout_session | ACP get. |
update_checkout_session | ACP update. |
complete_checkout_session | ACP complete. |
cancel_checkout_session | ACP cancel. |
The checkout tools follow ACP's MCP binding: arguments are {meta, id, payload}, where meta
carries api_version (required) and idempotency_key, and payload is the REST body. The
result's structuredContent is the same checkout session the REST API answers with.
A refusal comes back as a tool error (isError: true) whose text is the ACP error object, not
as a JSON-RPC error.
Hearing about the order afterwards
Register where your orders' events go:
curl -X PUT https://api.sporty.plus/api/acp/webhook \
-H "Authorization: Bearer $TOKEN" -H 'API-Version: 2026-04-17' \
-H 'Content-Type: application/json' \
-d '{ "url": "https://agent.example/agentic_commerce/webhooks/order_events", "secret": "…" }'
Bring your own secret of at least 24 characters, or leave it out and one is made and shown to
you once. GET shows the webhook and its last delivery; DELETE removes it. The URL must be
https on a public address.
We send ACP order events, each with the whole order:
order_createwhen a checkout is paid;order_updatewhen the booking is cancelled and refunded. The order'sstatusbecomescanceled, and itsadjustmentslist the refund.
POST /agentic_commerce/webhooks/order_events
Merchant-Signature: t=1790000000,v1=5f2c…
Content-Type: application/json
{"type":"order_create","data":{"type":"order","id":"ord_48213","checkout_session_id":"cs_…","status":"confirmed", …}}
v1 is the hex HMAC-SHA256 of {t}.{body} with your secret. Refuse a t more than five
minutes old. Delivery is at least once: a failed delivery is retried with a doubling wait, up
to ten times.
Cancelling and refunds
A booking bought through an agent is refunded to the card it was paid with when it is
cancelled, within the club's cancellation deadline. The buyer can cancel in the Sporty app, or
through DELETE /api/partner/v1/me/reservations/{id} if the agent holds me.reservations.write.
The refund includes any card surcharge.
Not supported yet
- Anything but court time: memberships, events and shop items.
- 3-D Secure challenges.
- Splitting the price between players. The buyer books the court and pays for all of it; partners can be added in the app.
- More than one slot per checkout.
- A product feed. Court time changes by the minute, so agents find it with
search_availability.