OAuth & Sign in with Sporty

Agent checkout

Let an AI agent find free court time at a club and pay for it, through the Agentic Commerce Protocol or MCP.

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, at https://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.

A club sells through agents only once it has switched agent checkout on. Everything else on this page answers 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.

TokenScopeThe buyer
A member's, from the authorization code flowme.purchases.writeThe member. They pay their own membership price, and the booking is theirs.
An approved platform's client_credentials tokenclub.checkout.write, granted by the clubA 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_sessionsOpen 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}/completePay and book.
POST /api/acp/checkout_sessions/{id}/cancelAbandon 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 its totals.
  • totals: items_base_amount, subtotal, tax, fee (a club's card surcharge, when it has one) and total. A US club adds sales tax on top. A club whose prices include VAT shows the VAT inside the price, as tax with "(included)".
  • fulfillment_options: always one digital option. Nothing ships; the booking shows in the Sporty app.
  • capabilities.payment.handlers: one handler, stripe_spt (dev.acp.tokenized.card). Its config.merchant_id is 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:

codeWhat happenedSession
payment_declinedThe card was refused. Nothing was booked.ready_for_payment: try another token.
requires_3dsThe card asks for 3-D Secure, which agent checkout does not support yet.ready_for_payment
out_of_stockSomeone 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.
missingA guest's email or name is missing.not_ready_for_payment
requires_sign_inA 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_clubsClubs that sell court time through agents.
search_availabilityFree starts at a club, priced for the buyer.
create_checkout_sessionACP create.
get_checkout_sessionACP get.
update_checkout_sessionACP update.
complete_checkout_sessionACP complete.
cancel_checkout_sessionACP 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_create when a checkout is paid;
  • order_update when the booking is cancelled and refunded. The order's status becomes canceled, and its adjustments list 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.