Widget API
Auto-generated from the OpenAPI spec. Run
node docs-site/scripts/fetch-openapi.jsto regenerate.
GET /api/v1/widget/{public_studio_id}/schedule
Get Widget Schedule
Public upcoming-class schedule for one studio's embeddable widget.
IP-keyed rate limit (get_remote_address, same key_func as the unauthenticated calendar feed at calendar_sync.py) since there is no JWT here to key on.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
public_studio_id | path | Yes | string |
Responses
- 200 — Successful Response
- 422 — Validation Error
GET /api/v1/widget/{public_studio_id}/membership-types
Get Widget Membership Types
Public membership-plan catalog for one studio's embeddable widget.
Only plans a manager has explicitly marked sellable_online are
exposed — the same gate create_checkout_session (app/routers/
stripe_billing.py) enforces at purchase time via MEMBERSHIP_TYPE_NOT_ONLINE,
so this list only ever shows plans that can actually be bought.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
public_studio_id | path | Yes | string |
Responses
- 200 — Successful Response
- 422 — Validation Error
GET /api/v1/widget/{public_studio_id}/appointment-services
Get Widget Appointment Services
Public appointment-service catalog for one studio's embeddable widget.
Scoped via AppointmentService.location_id — the multi-tenancy
partition key, same as get_widget_schedule's ScheduledClass.location_id
filter. Not to be confused with establishment_ids
(AppointmentServiceLocation, app/models/appointment_service_location.py):
that is a within-studio branch concept used for instructor-availability
matching. A service with zero establishment links is still offered
"everywhere" within its own studio, so that linkage is never used here
to filter a service out.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
public_studio_id | path | Yes | string |
Responses
- 200 — Successful Response
- 422 — Validation Error
GET /api/v1/widget/{public_studio_id}/products
Get Widget Products
Public retail-product catalog for one studio's embeddable widget.
Filtered to active, in-stock products only — a client browsing the widget before logging in should never see something they can't actually buy right now (app/routers/products.py's manager-only catalog has no such filter since managers need to see everything).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
public_studio_id | path | Yes | string |
Responses
- 200 — Successful Response
- 422 — Validation Error
GET /api/v1/widget/{public_studio_id}/products/{product_id}/photo
Get Widget Product Photo
Public product-photo file for one studio's embeddable widget.
GET /api/v1/photos/{filename} (app/routers/photos.py) requires
Depends(require_authenticated) because it also serves client/instructor
profile photos, which must stay non-public. Rather than loosen that
endpoint's auth (a privacy regression for profile photos), this is a
separate, narrowly-scoped public endpoint for product photos only.
The filename is never taken from the request — it is resolved from the studio's own Product row (scoped by public_studio_id -> location_id and product_id), same pattern as every other endpoint in this router. As defense in depth (docs/SECURITY_GUIDELINES.md §4.1), the resolved filename must both pass resolve_safe_photo_path's sanitization/ containment check (shared with photos.py) AND start with the "product_" prefix that validate_and_save_photo (app/routers/products.py's upload_product_photo) always gives product photos — in case photo_path was ever set to something unexpected. Any mismatch is a generic 404, not a 500.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
public_studio_id | path | Yes | string | |
product_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
POST /api/v1/widget/{public_studio_id}/products/{product_id}/checkout-session
Create Widget Product Checkout Session
Create a Stripe Checkout Session for a logged-in client to buy one retail product from the public widget.
Auth: client JWT required — mirrors create_checkout_session
(app/routers/stripe_billing.py) at a smaller scale: decode the token,
require type == "access". Unlike create_checkout_session there is no
"manager buying on behalf of a client" case here and no client_id in
the request body at all — the purchasing client is always the token's
own sub, so there is nothing for a malicious caller to substitute
(IDOR angle, docs/SECURITY_GUIDELINES.md §2).
No StripeCheckoutSession pending-row is created (unlike the membership
flow, whose StripeCheckoutSession.membership_type_id FK is NOT NULL
and not applicable here). Instead, like the gift-card self-purchase flow
(_handle_gift_card_checkout_completed in app/routers/stripe_billing.py),
everything needed to complete the sale on webhook confirmation travels
in the Stripe session's own metadata (purchase_type="product") — see
_handle_product_checkout_completed for the matching webhook handler.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
public_studio_id | path | Yes | string | |
product_id | path | Yes | integer |
Request body
| Field | Required | Type | Description |
|---|---|---|---|
success_url | Yes | string | |
cancel_url | Yes | string |
Responses
- 200 — Successful Response
- 422 — Validation Error