MADE Hoops Marketing Site API Contract
This document describes the current consumer API contract.
API base URL:
https://api.madeallin.com/api/v1
The previous base URL
https://r9fbmki8pq.us-east-1.awsapprunner.com/api/v1
continues to serve the same API, so clients can move to the address
above on their own schedule.
Client configuration must store this full API base URL. Every
endpoint path in this contract is relative to that base: call
${API_BASE_URL}/auth/sign-in, not
${API_BASE_URL}/api/v1/auth/sign-in. The origin is only the
scheme and host; it is not the request prefix.
Content-Type: application/json
Authentication: Endpoints that require an account
use Authorization: Bearer <sessionToken>. Every
endpoint below states its auth requirement.
CORS: Browser access is restricted to the configured MADE Hoops marketing origins, plus configured localhost origins for development.
Table of Contents
- Common flows
- Tokens
- Public players
- Public events
- Authentication
- Plans and billing
- Event registration
- Participant account
- Account edits
- Errors and rate limits
- Images and media
Common flows
Sign up or sign in
- Create an account with
POST /auth/sign-up, or sign in withPOST /auth/sign-in. - Store the returned session token and send it as
Authorization: Bearer <sessionToken>. - Refresh the signed-in account and entitlements with
GET /auth/me.
Browse events
- List active public events with
GET /public/events. - Use an event
idwithGET /public/events/:id. - Read
registrationfrom event detail for available units, divisions, prices, and open/full state.
Register a team
- Sign in as a coach. Select one of your coached teams from
GET /me/teams(§5.2) and pass itsteamId, or prepare new-team details. - Invite players by email through
rosterInvites. No player ids are needed. - Call
POST /registrations/initiatewithunit: "TEAM". - Once initiation returns an
eventTeamId, roster existing team members by id or copy them withbulk-from-teamthrough the §5.9 endpoints. - For card payment, save
continuationToken, redirect tocheckout.url, then use the boundedGET /registrations/order-statuspolicy in §5.5 after Stripe redirects back.
Register an individual
- A player uses their own account id. A parent gets a linked player id
from
GET /me/playersor creates a child withPOST /me/children. - Read the event's
registration.pricingand callPOST /registrations/initiatewithunit: "INDIVIDUAL"and exactly one roster entry. - Save
continuationToken, redirect to Stripe, then use the boundedGET /registrations/order-statuspolicy in §5.5.
TOURNAMENT events do not allow individual registration. On a tournament, join a team by invite or request, then pay for the spot through the per-player spot payment endpoint (§5.11).
Reset your password
POST /auth/forgot-passwordsends an email with a?token=link.- The reset page calls
GET /auth/validate-reset-tokento confirm the link and show the account email. POST /auth/reset-passwordsubmits the token and new password.
Manage your account
- Read or edit the profile with
GET /users/:idorPATCH /users/:id(§7). - Manage the subscription through the billing portal (§4.3).
- Change the password through the reset flow above.
Buy a spectator pass
- Read event detail and select an available
SPECTATORprice. - Call
POST /registrations/initiatewithunit: "SPECTATOR"and no team or roster fields. - Save
continuationToken, redirect to Stripe, then use the boundedGET /registrations/order-statuspolicy in §5.5. - A completed purchase appears in
GET /me/registration-orders. There is no QR or scan redemption endpoint.
Request to join a team
- Get an event-team id from
GET /public/events/:id/event-teams. - A player uses their own id. A parent gets a linked player id from
GET /me/players. - Call
POST /event-teams/:eventTeamId/roster/requests. - Track the request with
GET /me/roster-requests; the coach reviews it throughGET /event-teams/:eventTeamId/roster/requests.
Accept an invite
- Read the email token with
GET /registrations/roster-invites/:token. - Render the form identified by
acceptModeand retain the preview'spaymentRequiredflag. - Submit
POST /registrations/roster-invites/:token/accept. - When the response creates an account, store its returned session token.
- If
paymentRequiredis true, use that new session or ask an existing account holder to sign in, find the accepted registration inGET /me/registrations, and continue through the per-player spot-payment flow in §5.11.
Sign a waiver
- Find the registration in
GET /me/registrations(§6.5). - Check
GET /waivers/registrations/:registrationId(§5.13). A response withcompletion: nullmeans the waiver is outstanding; a404for a known-good registration id means the event has no published waiver — nothing to sign. Do not infer waiver state fromwaiverSignedalone. - Render the waiver body and collect the typed signature.
- Submit
POST /waivers/registrations/:registrationId/completewith thewaiverIdfrom step 2.
A parent must be linked to the player (§6.1, §6.2) before they can read or sign. Under-18 players cannot self-sign.
Subscribe
- Render available subscriptions from
GET /plans. - Sign in, then call
POST /checkout. - Redirect to the returned Stripe URL.
- After Stripe redirects back, refresh
GET /auth/meuntil the entitlement appears.
Tokens
| Token | Where it comes from | Where it is used | Lifetime and handling |
|---|---|---|---|
| Session JWT | POST /auth/sign-up, POST /auth/sign-in, or
account-creating invite acceptance |
Authenticated account endpoints in
Authorization: Bearer <sessionToken> |
7 days. Store as an account credential.
POST /auth/sign-out revokes every session token issued to
the account; also discard it locally. |
continuationToken |
POST /registrations/initiate or the roster spot-payment
endpoint |
GET /registrations/order-status in the Authorization
header |
2 hours. Store in sessionStorage before redirecting to
Stripe. Never put it in a URL. |
| Waiver signing token | Issued by event staff at the venue (staff tooling, not a marketing-site endpoint) | ?token= query on the §5.13 signing-token endpoints |
15 minutes, bound to one registration and waiver version. Render and submit immediately; never store it. |
1. Public Players
1.1 List Players
GET /players
Auth: none
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
search |
string | none | Search by player name (first or last, case-insensitive) |
state |
string | none | Filter by state code (e.g., GA, NJ) |
position |
enum | none | PG, SG, SF, PF,
C, G, F, GF |
gender |
enum | none | MALE, FEMALE |
gradYear |
number | none | Filter by graduation year (e.g., 2026) |
page |
number | 1 |
Page number (min: 1) |
limit |
number | 25 |
Results per page (min: 1, max: 100) |
sortBy |
enum | name |
name, rank, gradYear,
position, height, state |
sortOrder |
enum | asc |
asc, desc |
Response: 200 OK
{
"data": [
{
"firstName": "Aaron",
"lastName": "Bradshaw",
"photo": "https://d13hogaackalgg.cloudfront.net/raw/players/abc-123.jpg",
"classRank": 1,
"gradYear": 2026,
"position": "C",
"height": 84,
"state": "NJ",
"gender": "MALE",
"team": {
"id": "uuid",
"name": "NJ Scholars 2026"
},
"trophies": [
{
"id": 1,
"name": "EYBL Select",
"logo": "https://www.madehoops.com/uploads/trophies/eybl.png"
}
],
"slug": "aaron-bradshaw-2026"
}
],
"total": 7121,
"page": 1,
"limit": 25,
"totalPages": 285,
"counts": {
"total": 8220,
"male": 4202,
"female": 4018
}
}Notes:
photois a full-size absolute URL, ornullif no photo exists.slugis always present (non-null) for every player.- Public player responses do not expose the player's internal account
id. Use
slugfor navigation and fetch detail withGET /players/:slug. - For class rankings:
?gradYear=2026&sortBy=rank&sortOrder=asc heightis in inches.- Use
countsfor gender toggle labels.
Player photo sizing
Player photos are served from our CDN at
https://d13hogaackalgg.cloudfront.net/raw/players/<uuid>.<ext>.
Append a ?w= query parameter to fetch a pre-baked
variant:
| Query | Output | Use case |
|---|---|---|
?w=85 |
85×85 JPEG, face-centered | Player Finder grid thumbnails, list rows |
?w=500 |
500×500 JPEG, face-centered | Player detail / card hero |
| (none) | Original full-size | Archive / "view full size" links |
Variants are 1:1 squares with a face-centered crop. Only
85 and 500 are pre-baked. Any other
?w= value falls through to the original.
<img src={`${player.photo}?w=85`} width="85" height="85" />
<img src={`${player.photo}?w=500`} width="500" height="500" />Cache headers:
Cache-Control: public, max-age=31536000, immutable.
1.2 Get Player Detail
GET /players/:slug
Auth: none
Params:
slug: Player's URL slug (lowercase alphanumeric with dashes, e.g.,aaron-bradshaw-2026)
Response: 200 OK
{
"player": {
"firstName": "Aaron",
"lastName": "Bradshaw",
"photo": "https://d13hogaackalgg.cloudfront.net/raw/players/abc-123.jpg",
"classRank": 1,
"gradYear": 2026,
"position": "C",
"height": 84,
"weight": 220,
"state": "NJ",
"city": "Camden",
"gender": "MALE",
"school": { "id": 1, "name": "Camden High School" },
"ratingOverall": 95,
"ratingShooting": 80,
"ratingBallSkills": 78,
"ratingPassing": 75,
"ratingRebounding": 92,
"ratingFinishing": 90,
"ratingIQ": 85,
"ratingDefense": 88,
"ratingAthleticism": 95,
"ratingMotor": 90,
"ratingGrowthPotential": 92,
"collegeProjection": "High Major D1",
"instagram": "@abradshaw",
"twitter": null,
"tiktok": null,
"isAlumni": false,
"isEybl": true,
"slug": "aaron-bradshaw-2026",
"team": {
"id": "uuid",
"name": "NJ Scholars 2026"
},
"trophies": [
{ "id": 1, "name": "EYBL Select", "logo": "https://www.madehoops.com/uploads/trophies/eybl.png" }
],
"strengths": [
{ "id": 1, "name": "Rebounding" }
],
"weaknesses": [
{ "id": 3, "name": "Perimeter Shooting" }
],
"collegeInterests": [
{ "name": "Duke University", "division": 1, "conference": "ACC" }
]
}
}Errors:
400: Invalid slug format (must be lowercase alphanumeric with dashes)404: Player not found or not publicly visible
Notes:
- All rating fields are 0-100 or
nullif unrated. photoisnullif the player has no photo on file.- The player object does not expose the internal account id. Use
slugas the public identifier. - Players with suffixes (Jr, III, etc.) have them in the slug:
daryll-hill-jr-2026.
1.3 Resolve Legacy Player ID
Resolves a legacy player ID (the GUID from old
PlayerProfile.aspx?id=<uuid> links) to the player's
canonical slug, so old links can be 301-redirected to
/players/:slug.
GET /players/resolve/:id
Auth: none
Params:
id: Legacy player GUID (UUID format, e.g.,3e776120-cedc-4f2c-a5ed-9c613dfc5e21)
Response: 200 OK
{
"slug": "aaron-bradshaw-2026"
}Errors:
400:idis not a valid UUID404: No publicly visible player matches the ID
Notes:
- This endpoint only returns a slug; fetch the full profile via
GET /players/:slug. - The slug is the single canonical public identifier. The detail endpoint does not accept legacy IDs, and no internal account id is returned.
- Hidden players (not publicly visible) return
404, same as unknown IDs.
2. Public Events
For both public event endpoints, startDate and
endDate are ISO datetime instants. The API does not
guarantee UTC midnight or expose an event timezone, so clients must not
assume these values are date-only. The legacy signupCost
field is in major USD units (350 means
$350.00); registration pricing uses integer
amountCents instead.
2.1 List Events
GET /public/events
Auth: none
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
search |
string | none | Search by event name or venue (case-insensitive, partial match) |
upcoming |
enum | none | true = future events only, false =
all |
status |
enum | none | open = only events currently open for registration (see
Notes) |
eventType |
enum | none | CIRCUIT_SESSION, TOURNAMENT,
CAMP, SHOWCASE |
state |
string | none | Filter by state code (e.g., GA, NJ) |
region |
enum | none | Filter by region: EAST, MIDWEST,
SOUTHEAST, SOUTH, WEST,
CANADA. Combines with state via AND. |
zip |
string | none | 5-digit zip code used as the center point for radius search. Must be
paired with radius. |
radius |
number | none | Search radius in miles (1–500). Must be paired with
zip. Events without geocoded coordinates are excluded. |
ageGroup |
string | none | Filter by age group code: 8U, 9U,
10U, 11U, 12U, 13U,
14U, 15U, 16U, 17U,
HIGH_SCHOOL. Matches events that include this age
group. |
gender |
enum | none | MALE, FEMALE |
page |
number | 1 |
Page number (min: 1) |
limit |
number | 25 |
Results per page (min: 1, max: 100) |
sortBy |
enum | startDate |
startDate, name, state,
region |
sortOrder |
enum | asc |
asc, desc |
Response: 200 OK
{
"data": [
{
"id": "uuid",
"name": "MADE Hoops Summer Showcase 2026",
"eventType": "SHOWCASE",
"startDate": "2026-06-15T00:00:00.000Z",
"endDate": "2026-06-17T00:00:00.000Z",
"state": "GA",
"city": "Atlanta",
"venue": {
"name": "Georgia World Congress Center",
"address": "285 Andrew Young Intl Blvd NW",
"city": "Atlanta",
"state": "GA",
"zip": "30313"
},
"description": "<p>Elite showcase featuring top talent...</p>",
"ageGroups": ["15U", "16U", "17U"],
"gender": "MALE",
"signupCost": 350,
"logo": "https://www.madehoops.com/uploads/events/summer-showcase.png",
"bannerLogo": "https://www.madehoops.com/uploads/events/summer-showcase-banner.png",
"circuit": {
"id": "uuid",
"name": "MADE Hoops Summer Circuit"
}
}
],
"total": 42,
"page": 1,
"limit": 25,
"totalPages": 2
}Standard age group codes:
| Code | Description |
|---|---|
8U |
8 and under |
9U |
9 and under |
10U |
10 and under |
11U |
11 and under |
12U |
12 and under |
13U |
13 and under |
14U |
14 and under |
15U |
15 and under |
16U |
16 and under |
17U |
17 and under |
HIGH_SCHOOL |
High school / varsity level |
Region codes:
| Code | States / Provinces |
|---|---|
EAST |
CT, DE, ME, MD, MA, NH, NJ, NY, PA, RI, VT, VA, DC |
MIDWEST |
IL, IN, IA, KS, KY, MI, MN, MO, NE, OH, WV, WI |
SOUTHEAST |
AL, FL, GA, LA, MS, NC, SC, TN |
SOUTH |
AR, CO, NM, OK, TX |
WEST |
AK, AZ, CA, HI, ID, MT, ND, NV, OR, SD, UT, WA, WY |
CANADA |
AB, BC, MB, NB, NL, NS, ON, PE, QC, SK |
Notes:
ageGroupsis always an array. Empty array[]means no age group specified.genderis"MALE","FEMALE", ornull(co-ed / unspecified).searchsearches event name and venue (case-insensitive partial match).- All query params can be combined (AND logic).
- Only active, non-private, MADE-hosted events are returned. Partner /
non-MADE events (
externalEvent) are excluded from list and detail. status=openfilters to events that have not ended and either have an active pricing tier whose deadline has not passed, or have no pricing tiers and a non-nullsignupCost. It does not check capacity, the configured registration window, public-registration enablement, or whether every offered unit is legal for the event type. Use event detail'sregistration.openandregistration.fullbefore rendering a registration entry point.zip+radiusrequires both params; supplying one without the other returns400.- Unknown zip codes return an empty result set.
- Radius search only returns events with populated
latitude/longitude. Ungeocoded events are excluded. venueis an object{ name, address, city, state, zip }when the event has any location data, ornullwhen it has none. Every inner field, includingname, may benull.sortBy=regionsorts alphabetically by region label (CANADA, EAST, MIDWEST, SOUTH, SOUTHEAST, WEST). Events with no recognized state sort to the end.descriptionis allowlisted HTML (p,br,strong,em,u,s,ul,ol,li,a) ornull. Render it as HTML; do not parse markdown or escape the tags. Empty copy isnull, not"". Lists are flat (no nestedul/ol).ahrefs are absolutehttp(s)only. No headings, images, classes, or inline styles.
2.2 Get Event Detail
GET /public/events/:id
Auth: none
Params:
id: Event UUID
Response: 200 OK
{
"id": "uuid",
"name": "MADE Hoops Summer Showcase 2026",
"eventType": "SHOWCASE",
"startDate": "2026-06-15T00:00:00.000Z",
"endDate": "2026-06-17T00:00:00.000Z",
"state": "GA",
"city": "Atlanta",
"venue": {
"name": "Georgia World Congress Center",
"address": "285 Andrew Young Intl Blvd NW",
"city": "Atlanta",
"state": "GA",
"zip": "30313"
},
"description": "<p>Elite showcase featuring top talent...</p>",
"ageGroups": ["15U", "16U", "17U"],
"gender": "MALE",
"signupCost": 350,
"logo": "https://www.madehoops.com/uploads/events/summer-showcase.png",
"bannerLogo": "https://www.madehoops.com/uploads/events/summer-showcase-banner.png",
"directorName": "John Smith",
"directorEmail": "john@madehoops.com",
"directorPhone": "+15551234567",
"admissionTicketsEnabled": true,
"admissionTicketsUrl": "https://tickets.example.com/summer-showcase",
"circuit": {
"id": "uuid",
"name": "MADE Hoops Summer Circuit"
},
"registration": {
"enabled": true,
"open": true,
"opensAt": "2026-05-01T00:00:00.000Z",
"closesAt": "2026-08-01T00:00:00.000Z",
"rosterDeadline": "2026-08-05T00:00:00.000Z",
"allowedUnits": ["TEAM", "INDIVIDUAL", "SPECTATOR"],
"deferredPaymentEnabled": false,
"delegatedPaymentEnabled": false,
"offlinePaymentEnabled": true,
"full": false,
"divisions": [
{ "id": 12, "name": "13U Black" }
],
"pricing": [
{
"unit": "TEAM",
"divisionId": 12,
"tierName": "Early Bird",
"amountCents": 35000,
"currency": "usd",
"closesAt": "2026-06-15T00:00:00.000Z"
},
{
"unit": "SPECTATOR",
"divisionId": null,
"tierName": "Spectator Pass",
"amountCents": 3000,
"currency": "usd",
"closesAt": null
}
]
}
}admissionTicketsUrl may be null.
registration is null when public registration
is not enabled. Its complete field rules are in §5.1.
description follows the same HTML rules as the list
endpoint (§2.1): allowlisted tags or null, render as HTML,
no markdown.
Errors:
400: Invalid UUID format404: Event not found or not publicly visible
2.3 List Event Teams (Public)
The joinable teams for an event. A team appears when it has no
registration order or its order is COMPLETED or
DEFERRED. Teams whose order is PENDING,
EXPIRED, or CANCELED are excluded. No PII,
contacts, or amounts are returned.
GET /public/events/:id/event-teams
Auth: none
Params:
id: Event UUID
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
page |
number | 1 |
Page number (≥ 1) |
limit |
number | 50 |
Page size (1–100) |
Response: 200 OK
{
"eventTeams": [
{
"eventTeamId": "uuid",
"teamId": "uuid",
"teamName": "NJ Scholars 2026",
"ageGroup": "GRADE_8",
"gender": "MALE",
"rosterOpen": true,
"rosterCount": 9,
"divisionName": "2028 Division"
}
],
"total": 12,
"page": 1,
"limit": 50,
"totalPages": 1
}Notes:
rosterOpenistruewhen the event has no roster deadline or the deadline is still in the future;divisionNameisnullwhen the team has no division assignment for this event.rosterCountcounts active roster entries only.ageGroupuses the team vocabulary shown in §5.2, not the event8U-style vocabulary.
Errors:
400: Invalid UUID format404: Event not found, inactive, private, or a non-MADE partner event
3. Authentication
3.1 Sign Up
POST /auth/sign-up
Auth: none
Request:
{
"email": "user@example.com",
"password": "password123",
"firstName": "John",
"lastName": "Doe",
"role": "PLAYER"
}role:PARENT(default),PLAYER,COACHpassword: minimum 8 characters
Response: 201 Created
{
"user": {
"id": "uuid",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": null,
"role": "PLAYER",
"status": "ACTIVE",
"cyclosoftId": null,
"createdAt": "2026-07-30T12:00:00.000Z",
"updatedAt": "2026-07-30T12:00:00.000Z",
"lastLoginAt": null,
"playerProfile": null
},
"token": "session-jwt",
"permissions": [],
"entitlements": []
}permissions is always an array of strings. It is empty
for PARENT, PLAYER, and COACH.
user.playerProfile is null when the account
has no player profile; otherwise it is
{ "dateOfBirth": "<ISO datetime>" | null }.
API-created DOB values submitted as YYYY-MM-DD are stored
at UTC midnight; treat DOB as a calendar date rather than an
instant.
Status Codes:
201: Account and session created400: Validation error, including invalid email, short password, blank name, or unsupported role409:{ "error": "User with this email already exists" }429: Rate limit reached
3.2 Sign In
POST /auth/sign-in
Auth: none
Request:
{
"email": "user@example.com",
"password": "password123"
}Response: 200 OK. Same shape as
sign-up, including a session token, string-array
permissions, and current entitlements.
Status Codes:
200: Signed in400: Validation error401:{ "error": "Invalid email or password" }for wrong credentials or a non-active account429: Rate limit reached
3.3 Sign Out
POST /auth/sign-out
Headers:
Authorization: Bearer <sessionToken>
Response: 200 OK
{ "success": true }Sign-out revokes every session token issued to the account before the
sign-out, across all devices. A revoked token receives 401
on any authenticated endpoint. Discard the token client-side as
well.
Status Codes:
200: Sign-out acknowledged401: Missing, invalid, expired, inactive-account, or merged-account session
3.4 Get Current User
GET /auth/me
Headers:
Authorization: Bearer <token>
Response: 200 OK
{
"user": {
"id": "uuid",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": null,
"role": "PLAYER",
"status": "ACTIVE",
"cyclosoftId": null,
"createdAt": "2026-07-30T12:00:00.000Z",
"updatedAt": "2026-07-30T12:00:00.000Z",
"lastLoginAt": "2026-07-30T12:00:00.000Z",
"playerProfile": {
"dateOfBirth": "2011-04-15T00:00:00.000Z"
}
},
"permissions": [],
"entitlements": [
{
"id": "uuid",
"type": "PAYWALL_ACCESS",
"status": "ACTIVE",
"plan": "month",
"expiresAt": "2026-05-20T00:00:00.000Z",
"cancelAtPeriodEnd": false,
"grantedBy": "stripe",
"grantedAt": "2026-04-20T00:00:00.000Z"
}
]
}See §4 for the entitlement shape. user.playerProfile is
null when absent; when present, dateOfBirth is
an ISO datetime or null. API-created DOB values submitted
as YYYY-MM-DD are stored at UTC midnight; treat DOB as a
calendar date when prefilling a YYYY-MM-DD input.
Status Codes:
200: Current account returned401: Missing, invalid, expired, inactive-account, or merged-account session404: User no longer exists
3.5 Forgot Password
POST /auth/forgot-password
Auth: none
Request:
{
"email": "user@example.com"
}Response: 200 OK, including when no
account exists
{
"success": true,
"message": "If an account exists with that email, a reset link has been sent."
}The emailed reset token is valid for one hour. The destination is chosen by account role:
PARENT,PLAYER,COACH:<marketing site>/reset-password?token=<uuid>STAFF,ADMIN:<admin portal>/reset-password?token=<uuid>
Status Codes:
200: Generic anti-enumeration response400:{ "error": "Invalid email address" }429: Per-email rate limit reached
3.6 Validate Reset Token
Use this before rendering the reset form.
GET /auth/validate-reset-token?token=<uuid-from-email>
Auth: none
Response: 200 OK
{
"valid": true,
"email": "user@example.com"
}Status Codes:
200: Token is valid400:{ "valid": false, "error": "Invalid token format" }whentokenis not a UUID400:{ "valid": false, "error": "Invalid or expired reset link" }when the token is unknown, used, expired, or attached to an unavailable invite
3.7 Reset Password
POST /auth/reset-password
Auth: none
Request:
{
"token": "uuid-from-email-link",
"password": "newPassword123"
}Response: 200 OK
{
"success": true,
"message": "Password has been reset successfully"
}Status Codes:
200: Password reset400: Standard validation error for an invalid token format or password shorter than 8 characters400:{ "success": false, "error": "Invalid or expired reset link" }400:{ "success": false, "error": "This reset link has already been used" }400:{ "success": false, "error": "This reset link has expired" }400:{ "success": false, "error": "This invite has been revoked" }400:{ "success": false, "error": "This invite has already been accepted" }400:{ "success": false, "error": "This invite has expired" }429: Rate limit reached
4. Plans, Checkout & Billing
The API fronts Stripe completely. The marketing site never calls Stripe directly, never hardcodes Stripe Price IDs, and never builds custom billing management UI. Four endpoints cover the full flow.
4.1 List Plans
GET /plans
Auth: none
Render the pricing page from this.
Response: 200 OK
{
"plans": [
{ "key": "month", "name": "Monthly", "description": "Full access", "amount": 1999, "currency": "usd", "interval": "month" },
{ "key": "year", "name": "Annual", "description": "Full access", "amount": 19999, "currency": "usd", "interval": "year" }
]
}amount is in the smallest currency unit (cents for USD).
key and interval mirror Stripe's native
recurring.interval. Subscription checkout currently accepts
"month" and "year".
Errors:
503: Plans temporarily unavailable (Stripe failure)
4.2 Create Checkout Session
POST /checkout
Headers:
Authorization: Bearer <sessionToken> (required)
Starts a subscription. The caller must be signed in. Returns a
Stripe-hosted Checkout URL; redirect the user to it, and Stripe
redirects them back to successUrl on completion.
Request:
{
"plan": "month",
"successUrl": "https://www.madehoops.com/checkout/success",
"cancelUrl": "https://www.madehoops.com/checkout/cancel"
}| Field | Required | Notes |
|---|---|---|
plan |
yes | "month" or "year" |
successUrl |
yes | Absolute URL |
cancelUrl |
yes | Absolute URL |
Response: 200 OK
{ "url": "https://checkout.stripe.com/c/pay/..." }Status Codes:
200: Checkout URL returned400: Validation error or unknown plan401: Missing or invalid session token404: Signed-in user no longer exists500: Stripe configuration or checkout-session failure
4.3 Billing Portal (Subscription Management)
GET /me/billing-portal?returnUrl=https://www.madehoops.com/account
Headers:
Authorization: Bearer <token>
Returns a short-lived Stripe Customer Portal URL. Redirect the user there. The portal handles cancellation, plan switching (with proration), payment method updates, and invoice history.
Response: 200 OK
{ "url": "https://billing.stripe.com/session/..." }Errors:
400: Missing or invalidreturnUrl401: Not authenticated404: User has no Stripe customer (never paid); hide the "Manage" button in this case500: Stripe configuration or billing-portal failure
4.4 Entitlement Shape (Paywall & Profile Page)
Every authenticated response (/auth/sign-up,
/auth/sign-in, /auth/me,
/users/:id) returns entitlements[].
{
id: string;
type: 'PAYWALL_ACCESS' | 'PREMIUM_CLIPS' | 'COACHING_RESOURCES';
status: 'ACTIVE' | 'EXPIRED' | 'REVOKED';
plan: string | null; // currently "month" | "year"; null for non-subscription grants
expiresAt: string | null; // ISO 8601
cancelAtPeriodEnd: boolean; // true = user scheduled cancellation
grantedBy: string; // 'stripe' | 'admin' | ...
grantedAt: string; // ISO 8601
}plan is passed through from Stripe's
recurring.interval. Treat it as an opaque string and
compare with ===. Do not parse it or assume the enum.
The server auto-coerces status to EXPIRED
once expiresAt is in the past, so the client doesn't need a
date comparison.
Paywall gate:
const active = entitlements.some(
e => e.type === 'PAYWALL_ACCESS' && e.status === 'ACTIVE'
);Profile page display:
const paid = entitlements.find(
e => e.type === 'PAYWALL_ACCESS' && e.status === 'ACTIVE'
);
if (!paid) {
// Show "Subscribe" CTA → POST /checkout
} else if (paid.cancelAtPeriodEnd) {
// "Cancels on {paid.expiresAt}. Changed your mind? [Manage subscription]"
} else {
// "Active: {paid.plan === 'month' ? 'Monthly' : 'Annual'} plan, renews {paid.expiresAt}"
// [Manage subscription] → GET /me/billing-portal
}4.5 Webhook Timing
After checkout redirects back to your successUrl, the
subscription entitlement takes a few seconds to appear on
/auth/me (Stripe fires
customer.subscription.created asynchronously). On the
success page, show "Activating…" and re-fetch /auth/me
after 2–3 seconds.
5. Event Registration
The end-to-end flow for registering a team, an individual player, or a spectator into an event. Team selection and registration initiation require a signed-in MADE account. Authenticated coach/player roster-management endpoints state their Bearer-token requirements below.
Flow:
- Use
GET /public/events/:idto read theregistrationobject, including whether registration is open, the allowed units, divisions, and current price. - Select one of the coach's teams from
GET /me/teams(§5.2) and pass itsteamId, or collect new-team details from the user. - (Optional) Use
POST /registrations/validate-codeto classify a single entered code (offline or discount) in one call before initiating. - Call
POST /registrations/initiateto create the registration order. For card payment, it returns a Stripe Checkout URL. Redirect the user to it. - After Stripe redirects back to your
successUrl, follow the bounded, visibility-awareGET /registrations/order-statuspolicy in §5.5 with thecontinuationToken.
5.1 Registration Config (on Event Detail)
GET /public/events/:id includes a
registration object describing the live registration state.
Render the registration CTA and form from this. Do not derive price from
signupCost when registration.pricing is
present.
{
"registration": {
"enabled": true,
"open": true,
"opensAt": "2026-05-01T00:00:00.000Z",
"closesAt": "2026-08-01T00:00:00.000Z",
"rosterDeadline": "2026-08-05T00:00:00.000Z",
"allowedUnits": ["TEAM", "SPECTATOR"],
"deferredPaymentEnabled": false,
"delegatedPaymentEnabled": false,
"offlinePaymentEnabled": true,
"full": false,
"divisions": [
{ "id": 12, "name": "13U Black" },
{ "id": 13, "name": "14U Orange" }
],
"pricing": [
{
"unit": "TEAM",
"divisionId": null,
"tierName": "Early Bird",
"amountCents": 35000,
"currency": "usd",
"closesAt": "2026-06-15T00:00:00.000Z"
},
{
"unit": "TEAM",
"divisionId": 12,
"tierName": "Early Bird",
"amountCents": 35000,
"currency": "usd",
"closesAt": "2026-06-15T00:00:00.000Z"
},
{
"unit": "TEAM",
"divisionId": 13,
"tierName": "13U Special",
"amountCents": 30000,
"currency": "usd",
"closesAt": null
},
{
"unit": "SPECTATOR",
"divisionId": null,
"tierName": "Spectator Pass",
"amountCents": 3000,
"currency": "usd",
"closesAt": null
}
]
}
}Notes:
registrationisnullwhen public registration is not enabled for the event.openistrueonly when the registration window is open and at least one selection inpricingis purchasable. Use it for the general registration state, then apply the entry-point matrix below to the selected unit.fullistruewhen the event has hit its registration capacity; show a "registration full" state instead of the playing-registration form. Spectator orders do not consume playing capacity.pricingcontains one entry per selectable (unit, division) combination, priced exactly as checkout or spot payment will charge it.divisionId: nullis the no-division selection. Look up the user's exact selection. A unit/division combination is purchasable only if it appears inpricingand is legal for the event type in the matrix below. Anything absent frompricingis closed.closesAtis when that price ends (show "price goes up" messaging). Events priced by flat signup cost instead of tiers emit the same per-combination entries withtierName: null.SPECTATORis admission only. It is purchasable only when an active spectator tier resolves; it never falls back to the event's flatsignupCost.- Amounts are integer cents.
offlinePaymentEnabledistruewhenever the event has an active offline-payment (pay-later) code. It turns on/off automatically as those codes are added or removed. Surface the pay-later code entry only when this istrue.deferredPaymentEnabledis the separate codeless "pay later without a code" toggle.delegatedPaymentEnabledcontrols theDELEGATEDTEAM path in §5.4. The event must also allowINDIVIDUALso rostered players can pay for their own spots.
| Event type | TEAM |
INDIVIDUAL |
SPECTATOR |
|---|---|---|---|
TOURNAMENT |
/initiate |
Spot payment only; never /initiate |
/initiate |
CIRCUIT_SESSION |
/initiate |
/initiate |
/initiate |
CAMP |
Not allowed | /initiate |
/initiate |
SHOWCASE |
Not allowed | /initiate |
/initiate |
5.2 Coach Teams (Select a Team to Register)
These routes return real team ids because the caller is an
authenticated coach of those teams. Select an existing team from
GET /me/teams and pass its id as
teamId when initiating a registration. Team
ageGroup uses the grade-based enum (GRADE_4
through GRADE_12, HIGH_SCHOOL,
COLLEGE, or UNSPECIFIED), not the event
8U-style vocabulary.
GET /me/teams?eventId=<uuid>
Headers:
Authorization: Bearer <sessionToken>
Response: 200 OK
{
"teams": [
{
"id": "uuid",
"name": "NJ Scholars 2026",
"ageGroup": "GRADE_8",
"gender": "MALE",
"season": "2026",
"city": "Camden",
"state": "NJ",
"coachMembership": {
"id": "uuid",
"isPrimaryCoach": true,
"createdAt": "2026-07-07T12:00:00.000Z"
},
"eventRegistration": {
"eventTeamId": "uuid",
"eventId": "uuid",
"isPaid": false,
"registrationDate": null,
"order": {
"id": "uuid",
"status": "PENDING",
"paymentOption": "STRIPE_CHECKOUT",
"amountDueCents": 50000,
"amountPaidCents": 0
}
}
}
]
}eventRegistration is null when the team has
no registration state for the supplied event.
POST /me/teams
Headers:
Authorization: Bearer <sessionToken>
Request:
{
"name": "New Heights",
"ageGroup": "GRADE_8",
"gender": "MALE",
"season": "2026",
"city": "New York",
"state": "NY"
}Creates a team and makes the caller the team's primary coach.
All team fields except id, name,
wins, losses, status,
createdAt, and updatedAt may be
null as shown.
Response: 201 Created
{
"team": {
"id": "uuid",
"name": "New Heights",
"ageGroup": "GRADE_8",
"division": null,
"gender": "MALE",
"season": "2026",
"city": "New York",
"state": "NY",
"wins": 0,
"losses": 0,
"logo": null,
"status": "ACTIVE",
"createdAt": "2026-07-30T12:00:00.000Z",
"updatedAt": "2026-07-30T12:00:00.000Z"
},
"coachMembership": {
"id": "uuid",
"teamId": "uuid",
"userId": "uuid",
"memberType": "COACH",
"jerseyNumber": null,
"position": null,
"isPrimaryCoach": true,
"user": {
"id": "uuid",
"email": "coach@example.com",
"firstName": "Morgan",
"lastName": "Coach"
},
"createdAt": "2026-07-30T12:00:00.000Z"
}
}ageGroup, division, gender,
season, city, state, and
logo may be null.
coachMembership.jerseyNumber and
coachMembership.position are null for the
coach membership.
Status Codes:
201: Team and primary coach membership created400: Validation error401: Missing/invalid session token403: Caller is not a coach404: Caller account no longer exists
5.3 Validate a Code
Preview a code before initiating so the checkout UI knows how to
render and how to initiate. One endpoint handles both code types: a
pay-later authorization code and a discount code. You do not need to
know the code's type in advance. The authoritative check happens at
/initiate (§5.4).
POST /registrations/validate-code
Headers:
Authorization: Bearer <sessionToken>
Request:
{ "eventId": "uuid", "code": "SAVE10", "unit": "TEAM", "divisionId": 12 }
(divisionId optional; unit is required so a
discount can be priced.)
Response: 200 OK, with one of these
bodies:
{ "valid": true, "type": "OFFLINE_PAYMENT", "code": { "codeType": "OFFLINE_PAYMENT", "expiresAt": "2026-08-01T00:00:00.000Z" } }{ "valid": true, "type": "DISCOUNT", "discountAmountCents": 1000, "netAmountCents": 9000 }{ "valid": false, "error": "Invalid or expired code" }Classification is offline-first (an offline authorization outranks a
discount), and a code string is unique per event across both types, so
the result is unambiguous. Then initiate (§5.4) with the field matching
the returned type. For
type: "OFFLINE_PAYMENT", send offlineCode plus
paymentOption: "DEFERRED_OFFLINE" and skip card collection.
For type: "DISCOUNT", send discountCode plus
paymentOption: "STRIPE_CHECKOUT".
For a discount, discountAmountCents is the amount taken
off and netAmountCents is what Stripe will charge. A
discounted total below the $0.50 Stripe minimum returns
{ "valid": false, "error": "Discounted total is below the minimum charge" }.
Previewing never touches Stripe or advances the code's usage.
This endpoint requires a signed-in account and is rate-limited. It
returns 401 for a missing or invalid session and
429 when the limit is reached.
5.4 Initiate Registration
POST /registrations/initiate
Headers:
Authorization: Bearer <sessionToken>
Creates the registration order and the unit-specific registration
aggregate in one call. A valid, live MADE account is required. Missing,
invalid, expired, wrong-purpose, deleted, or INACTIVE
sessions return 401. A MERGED account returns
{ "error": "account_merged" }.
Request:
{
"eventId": "uuid",
"unit": "TEAM",
"divisionId": 12,
"teamId": "uuid",
"rosterInvites": [
{ "email": "newkid@example.com", "firstName": "New", "lastName": "Kid" },
{ "email": "existing-player@example.com" }
],
"paymentOption": "STRIPE_CHECKOUT",
"successUrl": "https://www.madehoops.com/register/success",
"cancelUrl": "https://www.madehoops.com/register/cancel"
}| Field | Required | Notes |
|---|---|---|
eventId |
yes | Event UUID |
unit |
yes | TEAM, INDIVIDUAL, or
SPECTATOR. It must be in
registration.allowedUnits, which the API already filters to
units that are publicly initiable for the event type (e.g. a tournament
never advertises INDIVIDUAL even when it is configured
internally for spot payments). |
divisionId |
no | Must belong to the event; omit for event-wide pricing |
teamId |
TEAM only; one of
teamId/team |
Authenticated coach-owned team id from /me/teams
(§5.2). The caller must be a COACH member of that
team. |
team |
TEAM only; one of
teamId/team |
New-team details:
{ name (required), ageGroup?, gender?, season?, city?, state? }.
The COACH caller becomes the team's primary coach.
ageGroup uses the team enum
(GRADE_4–GRADE_12, HIGH_SCHOOL,
COLLEGE, UNSPECIFIED) |
roster |
see notes | Existing player ids. For any TEAM request,
roster + rosterInvites must total ≥ 1. For the
public TEAM pre-registration flow, omit this field, use
rosterInvites, and add existing members after initiation
(§5.9). Teamless INDIVIDUAL requires exactly 1. Omit for
SPECTATOR. Each entry is
{ playerId, dateOfBirth?, jerseyNumber?, position? }; DOB
is YYYY-MM-DD, from 1920-01-01 through today. |
rosterInvites |
TEAM only |
Up to 50 rows of { email, firstName?, lastName? }.
Invites players onto the roster by email in the same call. Omit for
INDIVIDUAL and SPECTATOR. |
paymentOption |
no | STRIPE_CHECKOUT (default),
DEFERRED_OFFLINE, or DELEGATED.
DELEGATED is TEAM only. |
successUrl / cancelUrl |
for STRIPE_CHECKOUT |
Absolute URLs |
offlineCode |
no | Offline-authorization code; requires
paymentOption: "DEFERRED_OFFLINE" |
discountCode |
no | Online discount code (§5.12); applies at Stripe checkout. Requires
card payment. Combining it with DEFERRED_OFFLINE returns
400. |
Roster player ids require consent. Every
playerId must reference a PLAYER account.
- For
INDIVIDUAL, the id must be the signed-in player or a player linked to the signed-in parent. - For an existing
TEAM, a coach may send a raw id only when that player is already an active member of the underlying team, has accepted an invite for this event team, or has an approved request for this event team. - An admin may place a player by id.
- A brand-new team cannot include raw roster ids. Put every new player
in
rosterInvitesand invite them by email.
A raw id without one of these consent relationships returns
403:
{
"error": "Add new players by email invite; a player must already be on your team or have accepted an invite to be rostered by id."
}Use GET /me/players and POST /me/children
for a parent's linked players. In the public pre-registration flow for a
team, send rosterInvites by email and do not ask the coach
for player ids. After initiation returns an eventTeamId,
roster existing members by id or copy permanent team players with the
§5.9 endpoints.
TEAM registration requires a signed-in
COACH. Teamless INDIVIDUAL creates one
approved registration with teamId: null and exactly one
roster entry (the player being registered); omit
rosterInvites, teamId, and team.
SPECTATOR accepts any signed-in role and creates an
order/payment only; omit roster,
rosterInvites, teamId, and
team.
For an individual registration, prefill DOB from
user.playerProfile.dateOfBirth or the selected
/me/players row, collect it when missing, and submit the
calendar date as roster[].dateOfBirth. The registration
stores a supplied DOB for waiver age eligibility. If it is omitted,
waiver self-signing falls back to the player's profile DOB; with neither
value, the player cannot self-sign, and an under-18 player still
requires a linked parent or guardian.
The event-type/unit matrix in §5.1 is enforced before any order or
Stripe work. In particular, TOURNAMENT INDIVIDUAL pricing
exists for roster spot payment (§5.11), not teamless initiation;
CAMP and SHOWCASE never accept
TEAM.
DELEGATED registers a team with no team fee so players
can join by invite/request and pay through §5.11. It requires
registration.delegatedPaymentEnabled: true and
INDIVIDUAL in registration.allowedUnits;
roster must be empty and rosterInvites must
contain at least one contact to satisfy the TEAM roster requirement. Do
not send discountCode or offlineCode. The
response has no checkout; the order is
COMPLETED with amountDueCents: 0. The $0
delegated order remains voidable until money is recorded against it.
A coach registering a team does not need player ids.
Pass rosterInvites and the API resolves each email as an
existing player, an existing parent, or a new-account invitation. Invite
emails must be unique within the request.
Response: 201 Created
{
"order": {
"id": "uuid",
"eventId": "uuid",
"unit": "TEAM",
"status": "PENDING",
"paymentOption": "STRIPE_CHECKOUT",
"amountDueCents": 35000,
"amountPaidCents": 0,
"currency": "usd",
"expiresAt": "2026-07-07T18:00:00.000Z"
},
"team": { "name": "NJ Scholars 2026", "ageGroup": "GRADE_8", "gender": "MALE", "season": "2026", "city": "Camden", "state": "NJ" },
"eventTeamId": "uuid",
"rosterCount": 8,
"invites": [
{ "email": "newkid@example.com", "status": "invited" },
{ "email": "existing-player@example.com", "status": "already_invited", "reason": "An active invite already exists for this email" }
],
"checkout": {
"url": "https://checkout.stripe.com/c/pay/...",
"sessionId": "cs_..."
},
"continuationToken": "eyJhbGciOi..."
}For INDIVIDUAL and SPECTATOR,
team and eventTeamId are null.
SPECTATOR also returns rosterCount: 0 and
invites: [].
checkoutis present only forSTRIPE_CHECKOUT. Redirect the user tocheckout.url.DELEGATEDreturns nocheckout; its order is alreadyCOMPLETEDat $0 and players pay their own spots through §5.11.invitesreports the outcome of everyrosterInvitesrow, in request order ([]when none were sent). Invite failures appear as row results and do not fail the registration:statusMeaning invitedInvite created and emailed already_invitedA pending invite or confirmed spot already exists for this email skippedThe account is ineligible, the player was previously removed, or a batch-level guard failed (e.g. the roster deadline passed mid-request) invited_email_failedThe invite exists but its email did not send. Use the roster-invite resend endpoint (§5.9). Show unsuccessful rows to the coach so they can correct the address or resend. If Stripe checkout cannot start, no live invites are created and no invite email is sent.
eventTeamIdis the event-team record created or reused for aTEAMregistration. It is the id used by the roster endpoints in §5.8 through §5.11 (/event-teams/:eventTeamId/...). It isnullfor teamlessINDIVIDUALandSPECTATOR.amountDueCentsis the resolved price for the selected unit/division at the current tier.continuationTokenis a 2-hour token for the order-status endpoint (§5.5). Persist it insessionStoragebefore redirecting to Stripe.Pending Stripe orders expire after 24 hours (
expiresAt). A caller's own unfinishedPENDING, unpaidSTRIPE_CHECKOUTattempt for the same team or teamless individual player is superseded immediately. Its Stripe session is expired and the order row is reused for a fresh checkout. Clients do not need to handle an active-order409for their own retry.Spectator purchases remain separate rows. A new initiation expires the same buyer's other
PENDING, unpaid spectator Stripe orders for that event before creating the new order.Expired or canceled unpaid team/teamless-individual orders remain reusable.
DEFERRED,COMPLETED, paid, and another caller's active orders are not superseded and still return409.
Other errors:
400: Validation: roster size rules ("TEAM registration requires at least one roster player or invite"), non-PLAYER roster ids, duplicate player ids, duplicaterosterInvitesemails, team fields orrosterInvitessupplied forINDIVIDUAL, roster/team fields supplied forSPECTATOR, unknown division, bothteamIdandteamsupplied,offlineCodewithoutDEFERRED_OFFLINE,DEFERRED_OFFLINEused forINDIVIDUALorSPECTATOR, event has no usable price ("Event is not priced for online signup")401: Session token is missing/invalid or its account is unavailable404: Event not found / not open for public registration, including inactive, private, or partner / non-MADE (externalEvent) events403:TEAMregistration by a non-coach, an unownedteamId, an unauthorized player selection forINDIVIDUAL, or a raw TEAM roster id without the required consent relationship409: Registration window closed, unit not allowed for the event type, TEAM roster deadline passed, playing capacity reached, team/player has a paid, deferred, completed, or another caller's active order for this event, teamless player blocked by an existing team registration, including one with no order, a registration state that support must resolve, roster plus invites below an event's minimum team size ("This event requires at least N players per team"), invalid or exhausted offline code, no active price for the selected unit/division ("Registration is not open for this selection")429: Rate limit reached
5.5 Order Status (after the Stripe redirect)
GET /registrations/order-status
Headers:
Authorization: Bearer <continuationToken>
The continuation token rides in the Authorization header. Never put it in the URL, where it would leak into access logs and browser history. The token is bound to the payer who initiated the order.
Response: 200 OK
{
"order": {
"id": "uuid",
"unit": "TEAM",
"status": "COMPLETED",
"paymentOption": "STRIPE_CHECKOUT",
"amountDueCents": 35000,
"amountPaidCents": 35000,
"currency": "usd",
"expiresAt": "2026-07-07T18:00:00.000Z",
"eventName": "Summer Classic",
"playerName": null,
"teamName": "Chicago Rise",
"eventStartDate": "2026-08-10T12:00:00.000Z",
"eventEndDate": "2026-08-12T20:00:00.000Z",
"venue": "MADE Hoops Center",
"city": "Brooklyn",
"purchaserEmail": "payer@example.com"
}
}Use these fields to render the confirmation directly from the status
response. playerName is populated for an individual or
spot-payment order; teamName is populated for a team order.
Both are null for a spectator order. venue,
city, and purchaserEmail may be
null when the corresponding existing record has no value.
purchaserEmail is the email recorded for the order's payer.
order.id is the only identifier in this response; it never
exposes event, registration, player, team, Stripe session, payment,
customer, or admin identifiers.
Key off status only. expiresAt is the
checkout window set at initiation and is not cleared
when the order completes.
status |
Meaning | UI |
|---|---|---|
PENDING |
Awaiting payment confirmation | Show "Finalizing your registration…" and follow the bounded policy below. |
COMPLETED |
Paid and registered | Success state |
DEFERRED |
Offline/deferred payment owed | Success state + "payment due" messaging |
EXPIRED / CANCELED |
Checkout abandoned or failed | Offer to restart registration |
Payment confirmation arrives via Stripe webhook a few seconds after
the redirect. Start with a 2–3 second polling interval, back off to at
most 10 seconds, and pause polling while the page is hidden. Stop
automatic polling after about 90 seconds of foreground activity rather
than waiting indefinitely. If the order remains PENDING or
a transient network error prevents confirmation, keep the saved
continuationToken, offer an explicit "Check status again"
action, and link the signed-in buyer to
GET /me/registration-orders. If those reads remain unclear,
show the order id for support. A polling timeout is not proof that
payment failed, so do not automatically start another checkout. A
404 is terminal for that continuation token and must not
use this retry path. For EXPIRED or CANCELED,
registration initiation can be retried; a spot-payment checkout can be
started again through §5.11.
Errors:
401: Invalid or expired continuation token (tokens live 2 hours; the confirmation email is the fallback receipt)404: Order not found. The token is valid, but the order no longer exists; treat it likeCANCELED.
A PENDING order whose 24-hour checkout window has lapsed
is reported as EXPIRED even if the backing record has not
been updated yet. You never need to calculate expiry from
expiresAt.
5.6 Deferred / Offline Payment
For events with deferredPaymentEnabled (or with a valid
offline code when offlinePaymentEnabled):
- Send
paymentOption: "DEFERRED_OFFLINE"(plusofflineCodeif the user has one; the code is required when deferred payment isn't broadly enabled). - No Stripe redirect. The order is created with
status: "DEFERRED"and a payment-due email goes to the registrant. Payment is collected in person / by staff. successUrl/cancelUrlare not required on this path.
5.7 Roster Invites
A coach invites a player onto an event roster by email (coach-side
endpoints live in the authenticated API). The invite email links to the
marketing site's accept page with ?token=<uuid>
appended. Emails are personalized: a player-account recipient is greeted
by name; a parent-account recipient gets parent wording naming their
player; a minor's invite is also copied to each linked parent. One token
— whoever opens it first accepts. The site renders that page from the
two public endpoints below. The token is the credential. No login is
required to accept, and accepting can create the account in the same
step.
Preview an Invite
GET /registrations/roster-invites/:token
Preview for the accept page. For a pending invite:
Auth: none. The invite token is the credential.
{
"eventName": "Summer Showcase",
"teamName": "Chicago Rise",
"email": "invitee@example.com",
"invitedByName": "Mike Coach",
"status": "PENDING",
"acceptMode": "SIGNUP",
"linkedChildren": [],
"dateOfBirth": "2013-06-15T00:00:00.000Z",
"paymentRequired": true,
"expiresAt": "2026-07-14T12:00:00.000Z"
}linkedChildren is populated only for
ADD_CHILD: the parent account's linked players as
{ "id", "firstName", "lastName" }, oldest link first, so
the page can offer one-click confirmation of an existing child.
dateOfBirth echoes the coach-supplied DOB from the invite
(null when none) — prefill the accept form's DOB field with it; a value
typed at accept wins. paymentRequired is always a boolean
for a pending invite. It is true when this is a delegated
team registration with a positive individual spot price, so acceptance
creates the approved spot but does not pay it. Retain the flag through
acceptance, then use the accepted player and event-team ids with the
authenticated §5.11 spot-payment flow. It is false when no
post-accept spot payment is owed. This flag says whether money is owed,
not whether checkout is currently available; §5.11 still enforces its
live pricing and registration guards.
For a dead invite only the status is disclosed:
{ "status": "ACCEPTED" | "REVOKED" | "EXPIRED" }.
acceptMode tells the page which form to render:
acceptMode |
Meaning | Render |
|---|---|---|
CONFIRM |
The email belongs to an existing player | A single "Accept your spot" button: POST an empty body |
SIGNUP |
No account for this email | Choose between two sign-up forms, either the player themselves
(player) or a parent creating the child
(parent + child). |
ADD_CHILD |
The email belongs to an existing parent | Offer linkedChildren as one-click picks (submit
existingPlayerId), plus the new-child form
(child) for a player not yet on the account. |
UNAVAILABLE |
The email belongs to a staff/coach/admin account | Error state; the invite can't be accepted |
Errors: 404: unknown token (also
returned after a resend rotates the token).
Accept an Invite
POST /registrations/roster-invites/:token/accept
Accepting the invite confirms the player's roster spot, creates the
approved event registration, and notifies the coach. Body by
acceptMode:
Auth: none. The invite token is the credential.
CONFIRM:{}SIGNUP(player):{ "player": { "firstName", "lastName", "password", "dateOfBirth" } }SIGNUP(parent):{ "parent": { "firstName", "lastName", "password" }, "child": { "firstName", "lastName", "email"?, "dateOfBirth" } }ADD_CHILD, existing child:{ "existingPlayerId": "<uuid>" }— must be a player linked to the invited parent account and active; cannot be combined with the sign-up payloadsADD_CHILD, new child:{ "child": { "firstName", "lastName", "email"?, "dateOfBirth" } }
Passwords are 8–128 chars. Emails are normalized to lowercase. Child
accounts are created without a password; the parent acts for them.
child.email is optional — omitted, the child is created
with no email and all communications route to linked parents (the child
cannot sign in until an email is added).
Responses:
200: accepted with an existing account:{ "invite": { …, "status": "ACCEPTED" } }201: accepted and an account was created. Same session-bootstrap shape as/auth/sign-up, plus the invite:
{
"invite": { "status": "ACCEPTED", "...": "..." },
"user": { "id": "…", "email": "…", "firstName": "…", "lastName": "…", "role": "PLAYER", "...": "..." },
"token": "<jwt>",
"permissions": [],
"entitlements": []
}In the parent+child sign-up, user/token are
the parent's session.
Errors:
400: missing/malformed sign-up details for the resolved mode, orexistingPlayerIdcombined with sign-up payloads403:existingPlayerIdis not a player linked to the invited parent account404: unknown token409: already accepted, an account already exists for the child email, or the invite was superseded mid-request410: invite revoked, expired, or the player was removed from the roster422: roster deadline has passed, or the invite email belongs to an inactive/non-player account
Retryable failures leave the invite PENDING, so the same
link can be retried after correcting the input:
400/422 (bad input, closed deadline,
inactive/non-player email) and a 409 from an
account-already-exists or roster conflict. Terminal failures cannot be
reused: 409 already accepted, a 409 where a
concurrent claim superseded the invite, and any 410
(revoked, expired, or player removed from the roster).
Both endpoints are rate-limited.
5.8 Roster Contacts
Add players to an event roster by email and optional coach-provided name. The caller must be authenticated as a coach member of the team or as an admin. Each contact is resolved independently against any existing player or parent account, then recorded as a pending invite. The request never includes player ids. Coach-provided names are stored on the invite as display hints.
POST /event-teams/:eventTeamId/roster/contacts
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUID
Request:
{
"contacts": [
{
"email": "newkid@example.com",
"firstName": "New",
"lastName": "Kid",
"dateOfBirth": "2013-06-15"
},
{
"email": "existing-player@example.com"
}
]
}| Field | Required | Notes |
|---|---|---|
contacts |
yes | Array of 1–100 contact objects |
contacts[].email |
yes | Email string, max 254 characters; trimmed and stored lowercase |
contacts[].firstName |
no | String or null, max 100 characters; if supplied as a
string, it must not be blank |
contacts[].dateOfBirth |
no | YYYY-MM-DD; stored on the invite and returned by the
invite preview to prefill the accept form |
contacts[].lastName |
no | String or null, max 100 characters; if supplied as a
string, it must not be blank |
Response: 201 Created
{
"results": [
{
"email": "newkid@example.com",
"status": "invited",
"invite": {
"id": "uuid",
"eventTeamId": "uuid",
"email": "newkid@example.com",
"playerId": null,
"playerName": null,
"contactName": "New Kid",
"invitedByName": "Mike Coach",
"status": "PENDING",
"expiresAt": "2026-07-17T12:00:00.000Z",
"lastSentAt": "2026-07-10T12:00:00.000Z",
"acceptedAt": null,
"revokedAt": null,
"createdAt": "2026-07-10T12:00:00.000Z"
}
},
{
"email": "existing-player@example.com",
"status": "invited",
"invite": {
"id": "uuid",
"eventTeamId": "uuid",
"email": "existing-player@example.com",
"playerId": "uuid",
"playerName": "Jordan Reed",
"contactName": null,
"invitedByName": "Mike Coach",
"status": "PENDING",
"expiresAt": "2026-07-17T12:00:00.000Z",
"lastSentAt": "2026-07-10T12:00:00.000Z",
"acceptedAt": null,
"revokedAt": null,
"createdAt": "2026-07-10T12:00:00.000Z"
}
}
]
}results preserves request order. After the full request
body passes validation, a contact-level account or invite problem does
not fail the batch:
status |
Meaning |
|---|---|
invited |
The contact resolved successfully and invite contains
the roster invite |
already_invited |
A pending invite or confirmed spot already exists;
reason explains the conflict |
skipped |
The account is ineligible or the player was previously removed;
reason explains why |
invited_email_failed |
The invite was created but its email failed; use the roster-invite resend endpoint (§5.9) |
Existing players are immediately added as active roster rows but remain unapproved until they accept the invite. Existing parents receive an add-child invite; unknown emails receive a sign-up invite. Acceptance follows §5.7.
Status Codes:
201: Batch processed; inspect every row inresults400: Invalid event-team UUID, malformed body, empty batch, more than 100 contacts, invalid email, or invalid name401: Missing or invalid session token403: Caller is neither a coach member of the team nor an admin404: Event team not found422: Roster deadline has passed before batch processing begins429: Rate limit reached
Contact-level 409, 410, 422,
and invite-email 502 conditions are converted into row
results rather than returned as the batch's HTTP status.
5.9 Coach Roster Management
These endpoints require a session bearer token. The roster list requires roster read access. The caller must be an admin, staff member, or a coach member of the event team's underlying team. Roster-invite endpoints require roster write access. The caller must be an admin or a coach member of that team.
List Event-Team Roster
GET /event-teams/:eventTeamId/roster
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUID
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
includeInactive |
boolean | false |
Set to true to include soft-removed roster entries |
Response: 200 OK
{
"eventTeam": {
"id": "event-team-uuid",
"eventId": "event-uuid",
"eventName": "Summer Showcase",
"teamId": "team-uuid",
"teamName": "MADE Elite"
},
"roster": [
{
"id": "roster-entry-uuid",
"eventTeamId": "event-team-uuid",
"playerId": "player-uuid",
"eventRegistrationId": "event-registration-uuid",
"jerseyNumber": 12,
"position": "SG",
"isActive": true,
"removedAt": null,
"createdAt": "2026-07-10T12:00:00.000Z",
"updatedAt": "2026-07-11T09:30:00.000Z",
"player": {
"id": "player-uuid",
"email": "player@example.com",
"firstName": "Jordan",
"lastName": "Reed"
},
"linkedParentEmails": ["parent@example.com"],
"registration": {
"id": "event-registration-uuid",
"paid": true,
"waiverSigned": false,
"isApproved": true,
"registrationOrderId": "registration-order-uuid"
}
}
]
}By default, only active entries are returned. With
includeInactive=true, active entries come first, followed
by inactive entries; each group is ordered oldest-first by roster-entry
createdAt. Each roster entry includes the player's
firstName and lastName, plus
jerseyNumber and position when set. It also
includes player.email and the
registration.isApproved and registration.paid
states. registration.waiverSigned flips to
true when the player's event waiver is completed
(§5.13).
| Field | Type | Notes |
|---|---|---|
eventTeam.id |
UUID | Event-team id |
eventTeam.eventId |
UUID | Event id |
eventTeam.eventName |
string | Event name |
eventTeam.teamId |
UUID | Underlying team id |
eventTeam.teamName |
string | Underlying team name |
roster[].id |
UUID | Roster-entry id |
roster[].eventTeamId |
UUID | Event team that owns the entry |
roster[].playerId |
UUID | Player account id |
roster[].eventRegistrationId |
UUID or null |
Linked event-registration id |
roster[].jerseyNumber |
integer or null |
Event-roster jersey number |
roster[].position |
string or null |
Event-roster position |
roster[].isActive |
boolean | Whether the roster entry is active |
roster[].removedAt |
ISO 8601 datetime or null |
Soft-removal time |
roster[].createdAt |
ISO 8601 datetime | Roster-entry creation time |
roster[].updatedAt |
ISO 8601 datetime | Roster-entry last-update time |
roster[].player.id |
UUID | Player account id |
roster[].player.email |
string | Player account email |
roster[].player.firstName |
string | Player first name |
roster[].player.lastName |
string | Player last name |
roster[].linkedParentEmails |
string[] | Emails of all parent accounts linked to the player, oldest link first; empty when none exist |
roster[].registration |
object or null |
Linked registration state; null before a registration
is linked |
roster[].registration.id |
UUID | Event-registration id |
roster[].registration.paid |
boolean | Whether the player's registration is paid |
roster[].registration.waiverSigned |
boolean | Whether the player's waiver is signed |
roster[].registration.isApproved |
boolean | Whether the player's registration is approved |
roster[].registration.registrationOrderId |
UUID or null |
Registration order linked to the player's registration |
Status Codes:
200: Roster returned400: Invalid event-team UUID or invalidincludeInactivevalue401: Missing or invalid session token403: Caller does not have roster read access404: Event team not found
Add a Roster Player by ID
POST /event-teams/:eventTeamId/roster
Headers:
Authorization: Bearer <sessionToken>
Request:
{
"playerId": "uuid",
"jerseyNumber": 12,
"position": "SG"
}playerId is required. jerseyNumber may be
an integer from 0 through 999 or null.
position may be a 1 to 50 character string or
null.
This endpoint is for a known, consented player. The id is accepted
only when the player is already a member of the underlying team, has
accepted an invite for this event team, has an approved request for this
event team, is the caller or the caller's linked child, or the caller is
an admin. The route's write-access gate means the normal marketing-site
caller is the team's coach, who must hold roster write access for the
event team. The consent relationships above belong to the player being
added — so by id a coach can only place a player who is already a team
member, has an accepted invite for this event team, or has an approved
request for it. Add every other player by email through
POST /event-teams/:eventTeamId/roster/contacts.
Response: 201 Created
{
"rosterPlayer": {
"id": "roster-entry-uuid",
"eventTeamId": "event-team-uuid",
"playerId": "player-uuid",
"eventRegistrationId": null,
"jerseyNumber": 12,
"position": "SG",
"isActive": true,
"removedAt": null,
"createdAt": "2026-07-30T12:00:00.000Z",
"updatedAt": "2026-07-30T12:00:00.000Z",
"player": {
"id": "player-uuid",
"email": "player@example.com",
"firstName": "Jordan",
"lastName": "Reed"
},
"linkedParentEmails": ["parent@example.com"],
"registration": null
}
}Status Codes:
201: Player added or a previously removed row reactivated400: Invalid ids or body401: Missing or invalid session403: No roster write access, or the player lacks the required consent relationship404: Event team or player not found409: Player is already on this roster or another active roster for the event422: Roster deadline passed, roster edits are locked, or the account is not a player
Update Jersey Number or Position
PATCH /event-teams/:eventTeamId/roster/:playerId
Headers:
Authorization: Bearer <sessionToken>
Request:
{
"jerseyNumber": 23,
"position": "PG"
}Send at least one field. Either field may be null; the
same size rules as direct add apply.
Response: 200 OK
{ "rosterPlayer": { "...": "same roster-player shape as the roster list" } }Status Codes:
200: Roster entry updated400: Invalid ids, body, or empty update401: Missing or invalid session403: No roster write access404: Event team or roster player not found422: Roster deadline passed or roster edits are locked
Confirm an Existing Roster Spot
POST /event-teams/:eventTeamId/roster/:playerId/confirm
Headers:
Authorization: Bearer <sessionToken>
The body is empty. The caller may be the player, a linked parent, the team's coach, or an admin. The endpoint confirms an existing active invite or request row and makes the linked registration approved. It cannot create a roster spot from nothing.
Response: 200 OK
{ "rosterPlayer": { "...": "same roster-player shape with registration.isApproved true" } }Status Codes:
200: Spot confirmed400: Invalid ids401: Missing or invalid session403: Caller cannot confirm for this player, or the consent relationship is missing404: Event team, player, or pending roster spot not found409: Player already has a roster spot on another team for this event422: Roster deadline passed, roster edits are locked, or the account is not a player
Copy Permanent Team Players
POST /event-teams/:eventTeamId/roster/bulk-from-team
Headers:
Authorization: Bearer <sessionToken>
The body is empty. Copies player memberships from the underlying team to the event roster. Team membership supplies the required consent relationship. Existing active entries and players already rostered elsewhere for the event are counted as skipped.
Response: 200 OK
{
"eventTeam": {
"id": "event-team-uuid",
"eventId": "event-uuid",
"eventName": "Summer Showcase",
"teamId": "team-uuid",
"teamName": "MADE Elite"
},
"roster": [],
"summary": {
"added": 8,
"reactivated": 1,
"skipped": 2
}
}Status Codes:
200: Copy completed400: Invalid event-team id401: Missing or invalid session403: No roster write access404: Event team not found422: Roster deadline passed or roster edits are locked
Get the Coach Request Queue
GET /event-teams/:eventTeamId/roster/requests?status=PENDING
Headers:
Authorization: Bearer <sessionToken>
status is optional and may be PENDING,
APPROVED, DECLINED, or WITHDRAWN.
Results are oldest first.
Response: 200 OK
{
"requests": [
{
"id": "uuid",
"eventTeamId": "uuid",
"playerId": "uuid",
"playerName": "Jordan Reed",
"playerEmail": "jordan@example.com",
"requestedByUserId": "uuid",
"requestedByName": "Dana Reed",
"requestedByEmail": "dana@example.com",
"status": "PENDING",
"resolvedByUserId": null,
"resolvedAt": null,
"createdAt": "2026-07-30T12:00:00.000Z",
"updatedAt": "2026-07-30T12:00:00.000Z"
}
]
}Status Codes:
200: Request queue returned400: Invalid event-team id or status401: Missing or invalid session403: No roster write access404: Event team not found
Remove a Roster Player
DELETE /event-teams/:eventTeamId/roster/:playerId
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUIDplayerId: Player-account UUID
This action removes the active roster entry and revokes that player's
pending invites for the event team. When the player has linked
registration history, the entry is soft-removed
(isActive: false with removedAt set) so that
history remains linked. Otherwise it is hard-deleted. The response's
removed object has the same roster-entry shape as the list
response, including linkedParentEmails.
Response: 200 OK
{
"removed": {
"id": "roster-entry-uuid",
"eventTeamId": "event-team-uuid",
"playerId": "player-uuid",
"isActive": false,
"removedAt": "2026-07-18T12:00:00.000Z",
"linkedParentEmails": ["parent@example.com"]
},
"mode": "soft"
}mode is soft or hard. Before
changing the roster, the endpoint enforces both roster eligibility
rules. A passed roster deadline returns 422 with
{ "error": "Roster deadline has passed" }. For a registered
team whose event disables post-registration roster edits, a coach
receives 422 with
{ "error": "Roster edits are locked after registration for this event" };
admins may override that post-registration lock, but not the roster
deadline.
Status Codes:
200: Player removed400: Invalid event-team or player UUID401: Missing or invalid session token403: Caller does not have roster write access404: Event team or roster player not found409: The player has a paid registration and must go through a refund workflow before removal422: Roster deadline has passed, or post-registration roster edits are locked for this coach
List Roster Invites
GET /event-teams/:eventTeamId/roster/invites
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUID
Response: 200 OK
{
"invites": [
{
"id": "uuid",
"eventTeamId": "uuid",
"email": "parent@example.com",
"playerId": "child-player-uuid",
"playerName": "Jordan Reed",
"linkedParentEmails": ["parent@example.com"],
"contactName": "Jordan Reed",
"invitedByName": "Mike Coach",
"status": "ACCEPTED",
"expiresAt": "2026-07-17T12:00:00.000Z",
"lastSentAt": "2026-07-10T12:00:00.000Z",
"acceptedAt": "2026-07-11T09:30:00.000Z",
"revokedAt": null,
"createdAt": "2026-07-10T12:00:00.000Z"
}
]
}All invites are returned newest-first by createdAt;
there is no pagination or status filter.
| Field | Type | Notes |
|---|---|---|
id |
UUID | Roster-invite id |
eventTeamId |
UUID | Event team that owns the invite |
email |
string | Addressed invite email |
playerId |
UUID or null |
Player account the invite currently resolves to |
playerName |
string or null |
Resolved player's full name |
linkedParentEmails |
string[] | Emails of all parents linked to the resolved player, oldest link first; empty when the invite has no resolved player or the player has no links |
contactName |
string or null |
Coach-provided contact name stored on the invite |
invitedByName |
string or null |
Full name of the user who created the invite |
status |
enum | PENDING, ACCEPTED, REVOKED,
or EXPIRED; derived from the invite timestamps and current
time |
expiresAt |
ISO 8601 datetime | Current invite-link expiration |
lastSentAt |
ISO 8601 datetime or null |
Most recent send-attempt time |
acceptedAt |
ISO 8601 datetime or null |
Acceptance time |
revokedAt |
ISO 8601 datetime or null |
Revocation time |
createdAt |
ISO 8601 datetime | Invite creation time |
For an accepted invite, playerId and
playerName identify the player who the invite resolved to.
When a parent accepts through the parent branch, these fields identify
the child player, not the account that owns the invited
email.
Status Codes:
200: Invite list returned400: Invalid event-team UUID401: Missing or invalid session token403: Caller does not have roster write access404: Event team not found
Resend a Roster Invite
POST /event-teams/:eventTeamId/roster/invites/:inviteId/resend
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUIDinviteId: Roster-invite UUID
Resend replaces the invite token, resets expiresAt to
seven days from the resend, updates lastSentAt, and sends a
new invite email. The previous token stops resolving immediately, so the
old public preview link returns 404. If a concurrent accept
claims the invite before the conditional update, resend returns
409 without rotating that accepted invite's token.
Response: 200 OK
{
"invite": {
"id": "uuid",
"eventTeamId": "uuid",
"email": "invitee@example.com",
"playerId": null,
"playerName": null,
"linkedParentEmails": [],
"contactName": "New Kid",
"invitedByName": "Mike Coach",
"status": "PENDING",
"expiresAt": "2026-07-24T12:00:00.000Z",
"lastSentAt": "2026-07-17T12:00:00.000Z",
"acceptedAt": null,
"revokedAt": null,
"createdAt": "2026-07-10T12:00:00.000Z"
}
}invite has the same full field contract as the list
response above.
The addressed invite email is contractual. If that send fails, the
token, expiresAt, and lastSentAt have already
been updated, and the endpoint returns 502 with
{ "error": "Internal server error" }; retry the resend
action to rotate again and make another send attempt. Any additional
player or linked-parent copies are best-effort and do not change a
successful response.
Status Codes:
200: Token rotated and the addressed invite email sent400: Invalid event-team or invite UUID401: Missing or invalid session token403: Caller does not have roster write access404: Invite not found for that event team409: Invite is already accepted or has been revoked, including a concurrent accept422: Roster deadline has passed429: Rate limit reached502: Token rotated, but the addressed invite email could not be sent; response body is masked as described above
Revoke a Roster Invite
POST /event-teams/:eventTeamId/roster/invites/:inviteId/revoke
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUIDinviteId: Roster-invite UUID
Revoke sets revokedAt on a pending invite, invalidating
its public token. If the invite resolved to a player and created an
active, unapproved roster row, revoke then removes that row using the
same hard/soft removal and roster-lock rules documented above. An
already-revoked invite is idempotent and returns its
REVOKED representation. An accepted invite cannot be
revoked.
Response: 200 OK
{
"invite": {
"id": "uuid",
"eventTeamId": "uuid",
"email": "invitee@example.com",
"playerId": "player-uuid",
"playerName": "Jordan Reed",
"linkedParentEmails": ["parent@example.com"],
"contactName": "Jordan Reed",
"invitedByName": "Mike Coach",
"status": "REVOKED",
"expiresAt": "2026-07-24T12:00:00.000Z",
"lastSentAt": "2026-07-17T12:00:00.000Z",
"acceptedAt": null,
"revokedAt": "2026-07-18T12:00:00.000Z",
"createdAt": "2026-07-10T12:00:00.000Z"
}
}invite has the same full field contract as the list
response above. If revoking a resolved player invite must remove its
unapproved roster row, that removal can return 422 for a
passed roster deadline or the post-registration roster lock. Admin lock
overrides do not apply to this cleanup. revokedAt remains
persisted if the cleanup returns an error. Invites without such a row do
not run the roster-removal eligibility check.
Status Codes:
200: Pending invite revoked, or already-revoked invite returned400: Invalid event-team or invite UUID401: Missing or invalid session token403: Caller does not have roster write access404: Invite not found for that event team409: Invite is already accepted, including a concurrent accept422: Removing the resolved player's unapproved roster row hit the roster deadline or post-registration roster lock
5.10 Roster Requests
A roster request is the inverse of an invite. A player or linked
parent requests a spot on an event team, then the team coach approves or
declines it. Staff and admins may create a request on a player's behalf.
A pending request to the same team cannot be submitted again. Requesting
another team in the same event marks the player's earlier pending
request WITHDRAWN and reports the withdrawn team in the
create response. A player may request again after a decline.
Request statuses are PENDING, APPROVED,
DECLINED, and WITHDRAWN.
WITHDRAWN means a newer request from the same player
superseded this request; it cannot be approved or declined.
Create a Roster Request
POST /event-teams/:eventTeamId/roster/requests
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUID
Request:
{
"playerId": "uuid"
}| Field | Required | Notes |
|---|---|---|
playerId |
yes | UUID of the player requesting the spot; must be the caller, the caller's linked child, or a player represented by staff/admin |
Response: 201 Created
{
"request": {
"id": "uuid",
"eventTeamId": "uuid",
"playerId": "uuid",
"playerName": "Jordan Reed",
"playerEmail": "jordan@example.com",
"requestedByUserId": "uuid",
"requestedByName": "Dana Reed",
"requestedByEmail": "dana@example.com",
"status": "PENDING",
"resolvedByUserId": null,
"resolvedAt": null,
"createdAt": "2026-07-10T12:00:00.000Z",
"updatedAt": "2026-07-10T12:00:00.000Z"
},
"withdrew": [
{
"teamName": "MADE Red"
}
]
}withdrew is empty when the player had no pending request
for another team in the event.
Status Codes:
201: Request created400: Invalid event-team UUID, player UUID, or request body401: Missing or invalid session token403: Caller is not the player, a linked parent, staff, or admin404: Event team or player not found, or the event is inactive, private, or a partner / non-MADE (externalEvent) event409: Player already has an active roster spot for this event, or already has a pending request for this team422: Roster deadline has passed, the target is not a player account, or the player account is inactive429: Rate limit reached
Approve a Roster Request
POST /event-teams/:eventTeamId/roster/requests/:requestId/approve
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUIDrequestId: Roster-request UUID
The caller must be the team's coach or an admin. The request body is empty:
{}Approval creates or reactivates the event roster row and links it to an approved event registration.
Response: 200 OK
{
"request": {
"id": "uuid",
"eventTeamId": "uuid",
"playerId": "uuid",
"playerName": "Jordan Reed",
"playerEmail": "jordan@example.com",
"requestedByUserId": "uuid",
"requestedByName": "Dana Reed",
"requestedByEmail": "dana@example.com",
"status": "APPROVED",
"resolvedByUserId": "uuid",
"resolvedAt": "2026-07-10T12:05:00.000Z",
"createdAt": "2026-07-10T12:00:00.000Z",
"updatedAt": "2026-07-10T12:05:00.000Z"
}
}An in-window request may still be approved after the roster deadline; only creation of new requests is deadline-gated.
Status Codes:
200: Request approved400: Invalid event-team or request UUID401: Missing or invalid session token403: Caller is neither the team's coach nor an admin404: Event team or roster request not found409: Request was already approved, declined, or withdrawn, including a concurrent resolution422: Post-registration roster edits are locked for this event, or the requested player no longer exists as a valid, active player account
Decline a Roster Request
POST /event-teams/:eventTeamId/roster/requests/:requestId/decline
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUIDrequestId: Roster-request UUID
The caller must be the team's coach or an admin. The request body is empty:
{}Response: 200 OK
{
"request": {
"id": "uuid",
"eventTeamId": "uuid",
"playerId": "uuid",
"playerName": "Jordan Reed",
"playerEmail": "jordan@example.com",
"requestedByUserId": "uuid",
"requestedByName": "Dana Reed",
"requestedByEmail": "dana@example.com",
"status": "DECLINED",
"resolvedByUserId": "uuid",
"resolvedAt": "2026-07-10T12:05:00.000Z",
"createdAt": "2026-07-10T12:00:00.000Z",
"updatedAt": "2026-07-10T12:05:00.000Z"
}
}Declining does not create roster or registration state. A later request from the same player is allowed.
Status Codes:
200: Request declined400: Invalid event-team or request UUID401: Missing or invalid session token403: Caller is neither the team's coach nor an admin404: Event team or roster request not found409: Request was already approved, declined, or withdrawn, including a concurrent resolution
5.11 Per-Player Spot Payment
Start an individual Stripe Checkout for one active roster spot that already has an approved, unpaid event registration. Payment is not acceptance. An unapproved spot must first be accepted through §5.7 or approved through §5.10. The player, a linked parent, staff, or an admin may start payment; coaches do not receive payment access merely by coaching the team.
POST /event-teams/:eventTeamId/roster/:playerId/pay
Headers:
Authorization: Bearer <sessionToken>
Params:
eventTeamId: Event-team UUIDplayerId: Player-user UUID for the approved roster spot
Request:
{
"successUrl": "https://www.madehoops.com/register/success",
"cancelUrl": "https://www.madehoops.com/register/cancel",
"divisionId": 12
}| Field | Required | Notes |
|---|---|---|
successUrl |
yes | Valid absolute URL Stripe redirects to after checkout succeeds |
cancelUrl |
yes | Valid absolute URL Stripe redirects to when checkout is canceled |
divisionId |
no | Positive integer division id; when supplied, it must belong to the
event and have active INDIVIDUAL pricing |
discountCode |
no | Online discount code (§5.12); applies at Stripe checkout |
Response: 201 Created
{
"checkoutUrl": "https://checkout.stripe.com/c/pay/...",
"orderId": "uuid",
"amountDueCents": 7500,
"continuationToken": "eyJhbGciOi..."
}Redirect the user to checkoutUrl. The order uses
unit: "INDIVIDUAL" and
paymentOption: "STRIPE_CHECKOUT"; Stripe webhook completion
marks the linked registration's paid field
true without changing its approval state.
amountDueCents is an integer number of USD cents.
continuationToken is a 2-hour token bound to this order
and payer. After Stripe redirects back to your successUrl,
use the bounded GET /registrations/order-status policy in
§5.5 with it as the Authorization: Bearer credential. It is
the same token type /registrations/initiate returns;
persist it in sessionStorage (not component state)
alongside orderId so a mid-checkout page refresh doesn't
strand the polling window.
Status Codes:
201: Checkout created400: Invalid UUID or body, invalid URL, invalid/foreign division id, or event has no usable online price401: Missing or invalid session token403: Caller is not the player, a linked parent, staff, or admin404: Event team not found, or no registration exists for this player and team409: Player is not active on the roster; spot is already paid; another payment is pending; an offline payment is recorded; individual payment is disabled; or individual pricing is not open for the selected division422: Registration has not been approved429: Rate limit reached
5.12 Discount Codes (Online Checkout)
Discount codes reduce the amount charged at Stripe checkout. They apply to card payment only. The deferred/offline path (§5.6) uses offline-authorization codes, not discount codes.
Preview a discount before checkout with the
single-field code validator (§5.3). For a discount code it returns
type: "DISCOUNT" with discountAmountCents (the
amount taken off) and netAmountCents (what Stripe will
charge). Previewing never touches Stripe or advances the code's
usage.
Applying the discount. Pass the code as
discountCode on /initiate (§5.4) or on
per-player /pay (§5.11); the server re-validates, prices,
and attaches it to the Stripe Checkout session. Checkout-time
rejections:
409: Invalid, expired, or exhausted discount code.400: Discounted total is below the minimum charge, or adiscountCodewas supplied on a deferred/offline payment (discounts require card checkout).
5.13 Waivers
Events may publish a player waiver. When one exists, every event
registration for that event carries its own signing requirement, and
status surfaces as waiverSigned on the §5.9 roster and §6.5
registration payloads. When the event has no published player waiver,
the GET below returns 404 — treat that as "no waiver to
sign." (The other endpoints also return 404 for different
causes; see each endpoint's error list.)
Who can read and sign (session endpoints). The
player themself, a parent linked to the player (§6.1, §6.2), and MADE
Hoops staff or admin accounts. Anyone else — including an unlinked
guardian — receives 403. Self-signing (the player's own
session, or any submission whose signerRelationship is
self or player) additionally requires a date
of birth on file and an age of 18 or older at signing time. Under-18
players cannot sign for themselves — a linked parent or guardian (or a
staff member) completes the waiver on their behalf. The on-site
signing-token endpoints authorize differently — see the end of this
section.
Get the Waiver for a Registration
Returns the published waiver, a participant snapshot, and the completion record once signed.
GET /waivers/registrations/:registrationId
Headers:
Authorization: Bearer <sessionToken>
Params:
| Param | Type | Description |
|---|---|---|
registrationId |
uuid | Event-registration id — id from §6.5, or
roster[].registration.id from §5.9 |
Response: 200 OK
{
"waiver": {
"id": 12,
"eventId": "uuid",
"title": "Player Liability Waiver",
"body": "Full waiver text to render verbatim…",
"version": 2,
"status": "PUBLISHED",
"appliesTo": "PLAYER",
"isCoachWaiver": false,
"publishedAt": "2026-07-01T12:00:00.000Z",
"archivedAt": null,
"createdAt": "2026-06-28T09:00:00.000Z",
"updatedAt": "2026-07-01T12:00:00.000Z"
},
"registration": {
"id": "uuid",
"eventId": "uuid",
"eventName": "Winter Jam",
"teamId": "uuid",
"teamName": "Queens Rise",
"playerId": "uuid",
"firstName": "Jordan",
"lastName": "Reed",
"email": "jordan@example.com",
"dateOfBirth": "2010-03-14T00:00:00.000Z",
"gradClass": "2028"
},
"completion": null
}completionisnulluntil the registration's waiver is signed; afterwards it is the samecompletionobject the complete endpoint returns.waiver.bodyis plain text and may contain{{signerName}},{{eventName}}, and{{date}}placeholders. The GET returns them raw — interpolate them client-side for display. Completion stores and returns the server-interpolated text ascompletion.resultContent.registration.teamId/teamNamearenullfor teamless individual registrations.gradClassis a string ornull.dateOfBirthis the registration's own value and can benulleven when the player's profile has a date of birth — the self-sign age check accepts either source.- Send
waiver.idaswaiverIdon the complete call so the recorded signature always matches the text that was shown.
Errors:
400: Invalid registration UUID401: Missing/invalid session token403: Caller has no access to this registration404: Registration not found, or the event has no published player waiver
Sign the Waiver
Records a typed signature with full evidence (signer, signature text,
waiver and participant snapshots) and sets
waiverSigned: true on the registration.
POST /waivers/registrations/:registrationId/complete
Headers:
Authorization: Bearer <sessionToken>
Request:
{
"waiverId": 12,
"signerName": "Casey Reed",
"signerEmail": "casey@example.com",
"signerRelationship": "parent",
"signatureText": "Casey Reed",
"acceptedTerms": true
}| Field | Type | Notes |
|---|---|---|
waiverId |
integer | Optional but recommended — send the waiver.id from the
GET; 404 if it is no longer the published version |
signerName |
string, 1–160 | Required |
signerEmail |
string, valid email, ≤254 | Optional |
signerRelationship |
string, 1–80 | Required; e.g. parent, guardian,
self |
signatureText |
string, 1–200 | Required; a typed name — letters, numbers, spaces, and
. , ' ’ - only, no markup |
acceptedTerms |
boolean | Required; must be exactly true |
Response: 200 OK
{
"completion": {
"id": "uuid",
"waiverId": 12,
"eventRegistrationId": "uuid",
"userId": "player-uuid",
"signerUserId": "signer-uuid",
"signerName": "Casey Reed",
"signerEmail": "casey@example.com",
"signerRelationship": "parent",
"acceptedTerms": true,
"signatureHash": "hex",
"snapshotHash": "hex",
"recordHash": "hex",
"completedAt": "2026-08-03T18:00:00.000Z",
"resultContent": "Full waiver text with the placeholders filled in…"
},
"duplicate": false
}- Completing sets
waiverSigned: trueon the registration (§5.9, §6.5). - Resubmitting the same signature fields (
signerName,signerEmail,signerRelationship,signatureText,acceptedTerms) for the same waiver is safe: the original completion returns withduplicate: true. Different values are rejected with409. signerUserIdis the authenticated signer's account;userIdis always the player.resultContentis the waiver body with placeholders interpolated server-side at completion time ({{date}}renders in US Eastern). Match that interpolation when displaying the waiver so the shown text and the stored record agree.
Errors:
400: Invalid registration UUID, or invalid body (disallowed signature characters,acceptedTermsnottrue)401: Missing/invalid session token403: No access to this registration; self-signing without a date of birth on file; or an under-18 self-sign404: Registration not found, or no published player waiver matchingwaiverId409: Already completed with different evidence
On-Site Signing (staff-issued link)
Staff can issue a short-lived signing link at the event. The marketing site only renders these pages; issuing the token is staff tooling outside this contract.
GET /waivers/signing-token?token=<signingToken>
POST /waivers/signing-token/complete?token=<signingToken>
- No
Authorizationheader — the query token is the credential (see the Tokens table). There is no account or parent-link check on these endpoints: possession of the staff-issued token authorizes the signing. The self-sign age rule still applies to the declaredsignerRelationship(self/playerrequires a date of birth on file and 18+). - The GET returns
{ waiver, registration }with the same shapes as above, withoutcompletion. - The POST takes the same request body and returns the same response
shape as the session-based complete endpoint, with two differences: omit
waiverId— the token pins the waiver version, so the field never selects one (though a supplied value must still pass validation) — andcompletion.signerUserIdisnullbecause there is no signed-in signer.
Errors:
400: Missing or malformedtokenquery parameter (must be 20–2000 characters)401: Invalid or expired signing token- Other codes as in the two endpoints above
6. Participant Account
Authenticated resources for the signed-in user's participation. All
require Authorization: Bearer <sessionToken>. Each
endpoint below documents its own ordering, pagination, and limits where
applicable.
6.1 Parent Players
Lists a parent's linked players for individual registration and
participation flows. The endpoint requires a live PARENT
session; the parent id always comes from the session.
GET /me/players
Response: 200 OK
{
"players": [
{
"id": "uuid",
"firstName": "Jordan",
"lastName": "Reed",
"email": "jordan@example.com",
"playerProfile": {
"dateOfBirth": "2013-06-15T00:00:00.000Z",
"height": 70,
"gender": "MALE",
"city": "Brooklyn",
"state": "NY",
"country": "US",
"address": "123 Court Street",
"zipCode": "11201",
"phoneNumber": "212-555-0100",
"region": "Northeast",
"gradYear": 2031,
"position": "PG",
"jerseyNumber": 4,
"weight": 150
}
}
]
}- Returns only linked, active users whose current role is
PLAYER. - Results are ordered by last name, first name, then id.
- A parent with no linked players receives
{ "players": [] }. playerProfileisnullwhen absent. Every profile field shown above is nullable.dateOfBirthis an ISO datetime in responses. API-created DOB values submitted asYYYY-MM-DDare stored at UTC midnight. Treat DOB as a calendar date when prefilling individual registration, collecting it when missing so waiver eligibility can be evaluated.heightis an integer count of inches, not formatted feet and inches.weightis integer pounds.
Errors:
401: Missing/invalid session token403: Caller is not a parent
6.2 Parent Child Creation and Editing
The sanctioned way for a parent to add a player outside roster-invite
acceptance. This endpoint requires a live PARENT session;
the parent id always comes from the session.
POST /me/children
Request:
{
"firstName": "Jordan",
"lastName": "Reed",
"email": "jordan@example.com",
"dateOfBirth": "2013-06-15",
"height": 70,
"gender": "MALE",
"city": "Brooklyn",
"state": "NY",
"country": "US",
"address": "123 Court Street",
"zipCode": "11201",
"phoneNumber": "212-555-0100",
"region": "Northeast",
"gradYear": 2031,
"position": "PG",
"jerseyNumber": 4,
"weight": 150
}email is optional. Omitted, the child account is created
with no email: all communications route to the linked parent(s), and the
child cannot sign in until an email is added later. A no-email child
always keeps at least one active, emailed parent link — the API refuses
removing the last one (and refuses deleting the parent account) until an
email is set on the player.
Response: 201 Created
{
"child": {
"id": "uuid",
"firstName": "Jordan",
"lastName": "Reed",
"email": "jordan@example.com"
},
"linkId": "uuid"
}- First name and last name are required;
email, when supplied, must be valid and unique. - Names are trimmed and the email is trimmed and normalized to lowercase.
- Creates an active, passwordless
PLAYERaccount and its parent-player link atomically. - An email already owned by any account is rejected; the API does not attach that account.
- Profile fields are optional. Height is integer inches; weight is
integer pounds;
genderisMALEorFEMALE; andpositionisPG,SG,SF,PF,C,G,F, orGF. - The profile field ranges are: height 36–96, weight 50–400,
graduation year 2000–2050, and jersey number 0–99. Send
dateOfBirthasYYYY-MM-DD. - Only the listed fields are persisted. Additional request keys are ignored.
Errors:
400: Invalid name, email, DOB, or profile field401: Missing/invalid session token403: Caller is not a parent422: Email already belongs to an account429: Rate limit reached
To edit an existing linked child profile:
PATCH /me/children/:id
The id is a player id returned by
GET /me/players. The caller must be a parent linked to that
active PLAYER account. The request accepts the same
optional profile fields as creation (dateOfBirth through
weight above), but not firstName,
lastName, or email. Send null to
clear a profile field. Additional request keys are ignored.
Request:
{
"height": 71,
"position": "SG",
"jerseyNumber": 12
}Response: 200 OK
{
"player": {
"id": "uuid",
"firstName": "Jordan",
"lastName": "Reed",
"email": "jordan@example.com",
"playerProfile": {
"dateOfBirth": "2013-06-15T00:00:00.000Z",
"height": 71,
"gender": "MALE",
"city": "Brooklyn",
"state": "NY",
"country": "US",
"address": "123 Court Street",
"zipCode": "11201",
"phoneNumber": "212-555-0100",
"region": "Northeast",
"gradYear": 2031,
"position": "SG",
"jerseyNumber": 12,
"weight": 150
}
}
}Errors:
400: Invalid player id or profile field401: Missing/invalid session token403: Caller is not a parent404: Player is not an active linked child
6.3 Coach Event Teams
The signed-in coach's event teams across every event. The same
"registered team" rule as §2.3 applies. A team appears when it has no
order or an order that is COMPLETED/DEFERRED;
abandoned checkouts
(PENDING/EXPIRED/CANCELED) are
excluded. Archived teams are omitted.
GET /me/event-teams?eventId=<uuid>
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
eventId |
uuid | none | Optional; scope to a single event |
Response: 200 OK
{
"eventTeams": [
{
"eventTeamId": "uuid",
"event": {
"id": "uuid",
"name": "Spring Classic",
"startDate": "2026-06-15T00:00:00.000Z"
},
"teamId": "uuid",
"teamName": "Harlem Flight",
"rosterCount": 9,
"rosterDeadline": "2026-06-10T00:00:00.000Z"
}
]
}rosterCountcounts active roster entries;rosterDeadlineisnullwhen the event has none.- Results are newest-first and capped at 200 rows.
Errors:
401: Missing/invalid session token403: Caller is not a coach
6.4 Roster Requests
The signed-in player's roster requests. A PARENT caller
sees every linked player's requests (grouped via
playerId/playerName); a PLAYER
caller sees their own.
GET /me/roster-requests
Response: 200 OK
{
"requests": [
{
"id": "uuid",
"eventTeamId": "uuid",
"playerId": "uuid",
"playerName": "Jordan Reed",
"status": "PENDING",
"teamName": "Bronx Ballers",
"event": {
"id": "uuid",
"name": "Fall Invitational",
"startDate": "2026-09-12T00:00:00.000Z"
},
"createdAt": "2026-07-10T12:00:00.000Z",
"resolvedAt": null
}
]
}statusisPENDING,APPROVED,DECLINED, orWITHDRAWN; a withdrawn request was superseded by that player's newer request for the same event.- Results are newest-first and capped at 200 rows.
Errors:
401: Missing/invalid session token403: Caller is not a player or parent
6.5 Event Registrations
The signed-in player's event registrations with
approval/payment/waiver flags. A PARENT caller sees every
linked player's registrations.
GET /me/registrations
Response: 200 OK
{
"registrations": [
{
"id": "uuid",
"eventTeamId": "uuid",
"playerId": "uuid",
"playerName": "Jordan Reed",
"event": {
"id": "uuid",
"name": "Winter Jam",
"startDate": "2026-12-05T00:00:00.000Z"
},
"teamId": "uuid",
"teamName": "Queens Rise",
"isApproved": true,
"paid": false,
"waiverSigned": true
}
]
}eventTeamIdisnullwhen no roster entry links the registration; use it to drive the per-player roster endpoints in §5.8 through §5.11.teamIdandteamNamearenullfor teamless individual registrations.waiverSignedrecords a completed waiver for the registration; the §5.13 GET is the authoritative signing state, and the signing flow is §5.13.- Results are newest-first and capped at 200 rows.
Errors:
401: Missing/invalid session token403: Caller is not a player or parent
6.6 Registration Orders
The signed-in buyer's registration orders for team, individual, and
spectator purchases. All account roles may read their own orders. A
parent purchase belongs on the dashboard from this endpoint even when
the participant-facing GET /me/registrations list is empty;
that other list is limited to the parent's linked players.
GET /me/registration-orders?page=1&limit=20
page defaults to 1. limit defaults to 20
and may be 1 through 100.
Response: 200 OK
{
"registrationOrders": [
{
"id": "uuid",
"unit": "SPECTATOR",
"status": "COMPLETED",
"paymentOption": "STRIPE_CHECKOUT",
"amountDueCents": 3000,
"amountPaidCents": 3000,
"createdAt": "2026-07-10T12:00:00.000Z",
"event": {
"id": "uuid",
"name": "Summer Classic",
"startDate": "2026-08-10T12:00:00.000Z",
"city": "Brooklyn",
"state": "NY"
}
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"totalPages": 1
}
}- Results are newest-first. Only orders whose
userIdis the signed-in account are returned. - Build the buyer's purchase/registration history from this list. Use
GET /me/registrationsseparately for linked-player approval, payment, and waiver actions. - A completed
SPECTATORorder appears in this list. The API has no QR, scan, pass-token, or check-in redemption endpoint.
Status Codes:
200: Orders returned400: Invalid pagination401: Missing or invalid session
7. Account Edits
7.1 Get the Participant Account
GET /users/:id
Headers:
Authorization: Bearer <sessionToken>
For participant use, id must be the signed-in user's id
from sign-up, sign-in, or GET /auth/me.
Response: 200 OK
{
"user": {
"id": "uuid",
"email": "parent@example.com",
"firstName": "Dana",
"lastName": "Reed",
"phone": null,
"role": "PARENT",
"status": "ACTIVE",
"cyclosoftId": null,
"createdAt": "2026-07-30T12:00:00.000Z",
"updatedAt": "2026-07-30T12:00:00.000Z",
"lastLoginAt": "2026-07-30T12:00:00.000Z",
"playerProfile": null,
"teamMemberships": [],
"entitlements": [],
"eventRegistrations": [],
"parentLinks": [],
"playerLinks": [],
"strengths": [],
"weaknesses": [],
"attributes": [],
"trophies": [],
"collegeInterests": []
}
}For player accounts, playerProfile contains profile and
rating fields, including dateOfBirth as an ISO datetime or
null. API-created DOB values submitted as
YYYY-MM-DD are stored at UTC midnight and should be treated
as calendar dates. The relationship arrays contain the signed-in user's
team memberships, event registrations, parent/player links,
entitlements, and public profile relationships.
eventRegistrations is capped at 50.
Status Codes:
200: Account returned400: Invalid user UUID401: Missing or invalid session403: The requested id is not the signed-in user404: User not found500: Internal server error
7.2 Update the Participant Name
PATCH /users/:id
Headers:
Authorization: Bearer <sessionToken>
For participant use, id must be the signed-in user's id.
Participants can change only their own first and last name.
Request:
{
"firstName": "Dana",
"lastName": "Reed-Smith"
}Both fields are optional, but each supplied value must be non-empty. Other schema-recognized fields are ignored for non-admin callers. Email and phone are not self-editable through this endpoint.
Response: 200 OK
{
"user": {
"id": "uuid",
"email": "parent@example.com",
"firstName": "Dana",
"lastName": "Reed-Smith",
"phone": null,
"role": "PARENT",
"status": "ACTIVE",
"cyclosoftId": null,
"createdAt": "2026-07-30T12:00:00.000Z",
"updatedAt": "2026-07-30T12:05:00.000Z",
"lastLoginAt": "2026-07-30T12:00:00.000Z"
}
}Status Codes:
200: Account updated400: Invalid user UUID or invalid body401: Missing or invalid session403: The requested id is not the signed-in user404: User not found409: Account was merged and cannot be edited
Use the password-reset flow in §3 to change a password. Use
GET /me/billing-portal for subscriptions, payment methods,
plan changes, and invoices.
8. Errors and Rate Limits
All errors return JSON:
{
"error": "Human-readable error message",
"details": [
{
"path": ["fieldName"],
"message": "Error description",
"code": "invalid_type"
}
]
}details is only present for validation errors (400).
Status Codes:
400: Bad request / validation error401: Not authenticated403: Authenticated but not authorized for the role, account relationship, team, player, or requested action404: Not found409: Conflict, including sign-up with an existing email ("User with this email already exists") and the registration conflicts listed in §5410: Gone (roster invite revoked/expired; see §5.7)422: Unprocessable (roster deadline passed, ineligible account; see §5.7)429: Rate limited500: Internal server error
8.1 Rate Limits
The following consumer operations have route-level rate limits:
- Sign-up
- Sign-in at
POST /auth/sign-in - Forgot-password, keyed by normalized email
- Password reset at
POST /auth/reset-password - All registration code-validation endpoints
- Registration initiation
- Parent child creation at
POST /me/children - Roster join-request creation
- Per-player spot payment
Forgot-password and code validation use stricter limits than ordinary family workflows. Normal interactive use should not reach these limits. Roster contact batches and roster-invite endpoints are also limited because they create accounts or send email.
9. Images & Media
Player Photos: See §1.1 Player photo sizing for the URL format, variants, face-centered crop, fallback behavior, and cache headers.
Event Logos / Banners: The logo and
bannerLogo fields on event responses are absolute image
URLs served from the images CDN
(https://d13hogaackalgg.cloudfront.net/events/…), or
null. Render as-is. Supported formats are PNG, JPEG, WebP,
and GIF. These URLs use
Cache-Control: public, max-age=31536000, immutable. A
changed image publishes under a new URL, so the URL is safe to cache
indefinitely.
Trophy Logos: Absolute URLs from
www.madehoops.com. Use as-is (same cache headers as
above).