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

Sign up or sign in

  1. Create an account with POST /auth/sign-up, or sign in with POST /auth/sign-in.
  2. Store the returned session token and send it as Authorization: Bearer <sessionToken>.
  3. Refresh the signed-in account and entitlements with GET /auth/me.

Browse events

  1. List active public events with GET /public/events.
  2. Use an event id with GET /public/events/:id.
  3. Read registration from event detail for available units, divisions, prices, and open/full state.

Register a team

  1. Sign in as a coach. Select one of your coached teams from GET /me/teams (§5.2) and pass its teamId, or prepare new-team details.
  2. Invite players by email through rosterInvites. No player ids are needed.
  3. Call POST /registrations/initiate with unit: "TEAM".
  4. Once initiation returns an eventTeamId, roster existing team members by id or copy them with bulk-from-team through the §5.9 endpoints.
  5. For card payment, save continuationToken, redirect to checkout.url, then use the bounded GET /registrations/order-status policy in §5.5 after Stripe redirects back.

Register an individual

  1. A player uses their own account id. A parent gets a linked player id from GET /me/players or creates a child with POST /me/children.
  2. Read the event's registration.pricing and call POST /registrations/initiate with unit: "INDIVIDUAL" and exactly one roster entry.
  3. Save continuationToken, redirect to Stripe, then use the bounded GET /registrations/order-status policy 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

  1. POST /auth/forgot-password sends an email with a ?token= link.
  2. The reset page calls GET /auth/validate-reset-token to confirm the link and show the account email.
  3. POST /auth/reset-password submits the token and new password.

Manage your account

  1. Read or edit the profile with GET /users/:id or PATCH /users/:id (§7).
  2. Manage the subscription through the billing portal (§4.3).
  3. Change the password through the reset flow above.

Buy a spectator pass

  1. Read event detail and select an available SPECTATOR price.
  2. Call POST /registrations/initiate with unit: "SPECTATOR" and no team or roster fields.
  3. Save continuationToken, redirect to Stripe, then use the bounded GET /registrations/order-status policy in §5.5.
  4. A completed purchase appears in GET /me/registration-orders. There is no QR or scan redemption endpoint.

Request to join a team

  1. Get an event-team id from GET /public/events/:id/event-teams.
  2. A player uses their own id. A parent gets a linked player id from GET /me/players.
  3. Call POST /event-teams/:eventTeamId/roster/requests.
  4. Track the request with GET /me/roster-requests; the coach reviews it through GET /event-teams/:eventTeamId/roster/requests.

Accept an invite

  1. Read the email token with GET /registrations/roster-invites/:token.
  2. Render the form identified by acceptMode and retain the preview's paymentRequired flag.
  3. Submit POST /registrations/roster-invites/:token/accept.
  4. When the response creates an account, store its returned session token.
  5. If paymentRequired is true, use that new session or ask an existing account holder to sign in, find the accepted registration in GET /me/registrations, and continue through the per-player spot-payment flow in §5.11.

Sign a waiver

  1. Find the registration in GET /me/registrations (§6.5).
  2. Check GET /waivers/registrations/:registrationId (§5.13). A response with completion: null means the waiver is outstanding; a 404 for a known-good registration id means the event has no published waiver — nothing to sign. Do not infer waiver state from waiverSigned alone.
  3. Render the waiver body and collect the typed signature.
  4. Submit POST /waivers/registrations/:registrationId/complete with the waiverId from 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

  1. Render available subscriptions from GET /plans.
  2. Sign in, then call POST /checkout.
  3. Redirect to the returned Stripe URL.
  4. After Stripe redirects back, refresh GET /auth/me until 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:

  • photo is a full-size absolute URL, or null if no photo exists.
  • slug is always present (non-null) for every player.
  • Public player responses do not expose the player's internal account id. Use slug for navigation and fetch detail with GET /players/:slug.
  • For class rankings: ?gradYear=2026&sortBy=rank&sortOrder=asc
  • height is in inches.
  • Use counts for 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 null if unrated.
  • photo is null if the player has no photo on file.
  • The player object does not expose the internal account id. Use slug as 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: id is not a valid UUID
  • 404: 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:

  • ageGroups is always an array. Empty array [] means no age group specified.
  • gender is "MALE", "FEMALE", or null (co-ed / unspecified).
  • search searches 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=open filters 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-null signupCost. 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's registration.open and registration.full before rendering a registration entry point.
  • zip + radius requires both params; supplying one without the other returns 400.
  • Unknown zip codes return an empty result set.
  • Radius search only returns events with populated latitude/longitude. Ungeocoded events are excluded.
  • venue is an object { name, address, city, state, zip } when the event has any location data, or null when it has none. Every inner field, including name, may be null.
  • sortBy=region sorts alphabetically by region label (CANADA, EAST, MIDWEST, SOUTH, SOUTHEAST, WEST). Events with no recognized state sort to the end.
  • description is allowlisted HTML (p, br, strong, em, u, s, ul, ol, li, a) or null. Render it as HTML; do not parse markdown or escape the tags. Empty copy is null, not "". Lists are flat (no nested ul/ol). a hrefs are absolute http(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 format
  • 404: 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:

  • rosterOpen is true when the event has no roster deadline or the deadline is still in the future; divisionName is null when the team has no division assignment for this event.
  • rosterCount counts active roster entries only.
  • ageGroup uses the team vocabulary shown in §5.2, not the event 8U-style vocabulary.

Errors:

  • 400: Invalid UUID format
  • 404: 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, COACH
  • password: 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 created
  • 400: Validation error, including invalid email, short password, blank name, or unsupported role
  • 409: { "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 in
  • 400: Validation error
  • 401: { "error": "Invalid email or password" } for wrong credentials or a non-active account
  • 429: 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 acknowledged
  • 401: 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 returned
  • 401: Missing, invalid, expired, inactive-account, or merged-account session
  • 404: 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 response
  • 400: { "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 valid
  • 400: { "valid": false, "error": "Invalid token format" } when token is not a UUID
  • 400: { "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 reset
  • 400: Standard validation error for an invalid token format or password shorter than 8 characters
  • 400: { "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 returned
  • 400: Validation error or unknown plan
  • 401: Missing or invalid session token
  • 404: Signed-in user no longer exists
  • 500: 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 invalid returnUrl
  • 401: Not authenticated
  • 404: User has no Stripe customer (never paid); hide the "Manage" button in this case
  • 500: 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:

  1. Use GET /public/events/:id to read the registration object, including whether registration is open, the allowed units, divisions, and current price.
  2. Select one of the coach's teams from GET /me/teams (§5.2) and pass its teamId, or collect new-team details from the user.
  3. (Optional) Use POST /registrations/validate-code to classify a single entered code (offline or discount) in one call before initiating.
  4. Call POST /registrations/initiate to create the registration order. For card payment, it returns a Stripe Checkout URL. Redirect the user to it.
  5. After Stripe redirects back to your successUrl, follow the bounded, visibility-aware GET /registrations/order-status policy in §5.5 with the continuationToken.

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:

  • registration is null when public registration is not enabled for the event.
  • open is true only when the registration window is open and at least one selection in pricing is purchasable. Use it for the general registration state, then apply the entry-point matrix below to the selected unit.
  • full is true when 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.
  • pricing contains one entry per selectable (unit, division) combination, priced exactly as checkout or spot payment will charge it. divisionId: null is the no-division selection. Look up the user's exact selection. A unit/division combination is purchasable only if it appears in pricing and is legal for the event type in the matrix below. Anything absent from pricing is closed.
  • closesAt is when that price ends (show "price goes up" messaging). Events priced by flat signup cost instead of tiers emit the same per-combination entries with tierName: null.
  • SPECTATOR is admission only. It is purchasable only when an active spectator tier resolves; it never falls back to the event's flat signupCost.
  • Amounts are integer cents.
  • offlinePaymentEnabled is true whenever 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 is true. deferredPaymentEnabled is the separate codeless "pay later without a code" toggle.
  • delegatedPaymentEnabled controls the DELEGATED TEAM path in §5.4. The event must also allow INDIVIDUAL so 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 created
  • 400: Validation error
  • 401: Missing/invalid session token
  • 403: Caller is not a coach
  • 404: 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_4GRADE_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 rosterInvites and 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: [].

  • checkout is present only for STRIPE_CHECKOUT. Redirect the user to checkout.url.

  • DELEGATED returns no checkout; its order is already COMPLETED at $0 and players pay their own spots through §5.11.

  • invites reports the outcome of every rosterInvites row, in request order ([] when none were sent). Invite failures appear as row results and do not fail the registration:

    status Meaning
    invited Invite created and emailed
    already_invited A pending invite or confirmed spot already exists for this email
    skipped The account is ineligible, the player was previously removed, or a batch-level guard failed (e.g. the roster deadline passed mid-request)
    invited_email_failed The 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.

  • eventTeamId is the event-team record created or reused for a TEAM registration. It is the id used by the roster endpoints in §5.8 through §5.11 (/event-teams/:eventTeamId/...). It is null for teamless INDIVIDUAL and SPECTATOR.

  • amountDueCents is the resolved price for the selected unit/division at the current tier.

  • continuationToken is a 2-hour token for the order-status endpoint (§5.5). Persist it in sessionStorage before redirecting to Stripe.

  • Pending Stripe orders expire after 24 hours (expiresAt). A caller's own unfinished PENDING, unpaid STRIPE_CHECKOUT attempt 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-order 409 for 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 return 409.

Other errors:

  • 400: Validation: roster size rules ("TEAM registration requires at least one roster player or invite"), non-PLAYER roster ids, duplicate player ids, duplicate rosterInvites emails, team fields or rosterInvites supplied for INDIVIDUAL, roster/team fields supplied for SPECTATOR, unknown division, both teamId and team supplied, offlineCode without DEFERRED_OFFLINE, DEFERRED_OFFLINE used for INDIVIDUAL or SPECTATOR, event has no usable price ("Event is not priced for online signup")
  • 401: Session token is missing/invalid or its account is unavailable
  • 404: Event not found / not open for public registration, including inactive, private, or partner / non-MADE (externalEvent) events
  • 403: TEAM registration by a non-coach, an unowned teamId, an unauthorized player selection for INDIVIDUAL, or a raw TEAM roster id without the required consent relationship
  • 409: 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 like CANCELED.

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" (plus offlineCode if 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 / cancelUrl are 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 payloads
  • ADD_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, or existingPlayerId combined with sign-up payloads
  • 403: existingPlayerId is not a player linked to the invited parent account
  • 404: unknown token
  • 409: already accepted, an account already exists for the child email, or the invite was superseded mid-request
  • 410: invite revoked, expired, or the player was removed from the roster
  • 422: 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 in results
  • 400: Invalid event-team UUID, malformed body, empty batch, more than 100 contacts, invalid email, or invalid name
  • 401: Missing or invalid session token
  • 403: Caller is neither a coach member of the team nor an admin
  • 404: Event team not found
  • 422: Roster deadline has passed before batch processing begins
  • 429: 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 returned
  • 400: Invalid event-team UUID or invalid includeInactive value
  • 401: Missing or invalid session token
  • 403: Caller does not have roster read access
  • 404: 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 reactivated
  • 400: Invalid ids or body
  • 401: Missing or invalid session
  • 403: No roster write access, or the player lacks the required consent relationship
  • 404: Event team or player not found
  • 409: Player is already on this roster or another active roster for the event
  • 422: 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 updated
  • 400: Invalid ids, body, or empty update
  • 401: Missing or invalid session
  • 403: No roster write access
  • 404: Event team or roster player not found
  • 422: 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 confirmed
  • 400: Invalid ids
  • 401: Missing or invalid session
  • 403: Caller cannot confirm for this player, or the consent relationship is missing
  • 404: Event team, player, or pending roster spot not found
  • 409: Player already has a roster spot on another team for this event
  • 422: 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 completed
  • 400: Invalid event-team id
  • 401: Missing or invalid session
  • 403: No roster write access
  • 404: Event team not found
  • 422: 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 returned
  • 400: Invalid event-team id or status
  • 401: Missing or invalid session
  • 403: No roster write access
  • 404: Event team not found

Remove a Roster Player

DELETE /event-teams/:eventTeamId/roster/:playerId

Headers: Authorization: Bearer <sessionToken>

Params:

  • eventTeamId: Event-team UUID
  • playerId: 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 removed
  • 400: Invalid event-team or player UUID
  • 401: Missing or invalid session token
  • 403: Caller does not have roster write access
  • 404: Event team or roster player not found
  • 409: The player has a paid registration and must go through a refund workflow before removal
  • 422: 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 returned
  • 400: Invalid event-team UUID
  • 401: Missing or invalid session token
  • 403: Caller does not have roster write access
  • 404: Event team not found

Resend a Roster Invite

POST /event-teams/:eventTeamId/roster/invites/:inviteId/resend

Headers: Authorization: Bearer <sessionToken>

Params:

  • eventTeamId: Event-team UUID
  • inviteId: 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 sent
  • 400: Invalid event-team or invite UUID
  • 401: Missing or invalid session token
  • 403: Caller does not have roster write access
  • 404: Invite not found for that event team
  • 409: Invite is already accepted or has been revoked, including a concurrent accept
  • 422: Roster deadline has passed
  • 429: Rate limit reached
  • 502: 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 UUID
  • inviteId: 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 returned
  • 400: Invalid event-team or invite UUID
  • 401: Missing or invalid session token
  • 403: Caller does not have roster write access
  • 404: Invite not found for that event team
  • 409: Invite is already accepted, including a concurrent accept
  • 422: 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 created
  • 400: Invalid event-team UUID, player UUID, or request body
  • 401: Missing or invalid session token
  • 403: Caller is not the player, a linked parent, staff, or admin
  • 404: Event team or player not found, or the event is inactive, private, or a partner / non-MADE (externalEvent) event
  • 409: Player already has an active roster spot for this event, or already has a pending request for this team
  • 422: Roster deadline has passed, the target is not a player account, or the player account is inactive
  • 429: Rate limit reached

Approve a Roster Request

POST /event-teams/:eventTeamId/roster/requests/:requestId/approve

Headers: Authorization: Bearer <sessionToken>

Params:

  • eventTeamId: Event-team UUID
  • requestId: 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 approved
  • 400: Invalid event-team or request UUID
  • 401: Missing or invalid session token
  • 403: Caller is neither the team's coach nor an admin
  • 404: Event team or roster request not found
  • 409: Request was already approved, declined, or withdrawn, including a concurrent resolution
  • 422: 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 UUID
  • requestId: 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 declined
  • 400: Invalid event-team or request UUID
  • 401: Missing or invalid session token
  • 403: Caller is neither the team's coach nor an admin
  • 404: Event team or roster request not found
  • 409: 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 UUID
  • playerId: 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 created
  • 400: Invalid UUID or body, invalid URL, invalid/foreign division id, or event has no usable online price
  • 401: Missing or invalid session token
  • 403: Caller is not the player, a linked parent, staff, or admin
  • 404: Event team not found, or no registration exists for this player and team
  • 409: 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 division
  • 422: Registration has not been approved
  • 429: 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 a discountCode was 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
}
  • completion is null until the registration's waiver is signed; afterwards it is the same completion object the complete endpoint returns.
  • waiver.body is 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 as completion.resultContent.
  • registration.teamId/teamName are null for teamless individual registrations. gradClass is a string or null. dateOfBirth is the registration's own value and can be null even when the player's profile has a date of birth — the self-sign age check accepts either source.
  • Send waiver.id as waiverId on the complete call so the recorded signature always matches the text that was shown.

Errors:

  • 400: Invalid registration UUID
  • 401: Missing/invalid session token
  • 403: Caller has no access to this registration
  • 404: 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: true on 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 with duplicate: true. Different values are rejected with 409.
  • signerUserId is the authenticated signer's account; userId is always the player.
  • resultContent is 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, acceptedTerms not true)
  • 401: Missing/invalid session token
  • 403: No access to this registration; self-signing without a date of birth on file; or an under-18 self-sign
  • 404: Registration not found, or no published player waiver matching waiverId
  • 409: Already completed with different evidence

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 Authorization header — 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 declared signerRelationship (self/player requires a date of birth on file and 18+).
  • The GET returns { waiver, registration } with the same shapes as above, without completion.
  • 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) — and completion.signerUserId is null because there is no signed-in signer.

Errors:

  • 400: Missing or malformed token query 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": [] }.
  • playerProfile is null when absent. Every profile field shown above is nullable.
  • dateOfBirth is an ISO datetime in responses. API-created DOB values submitted as YYYY-MM-DD are stored at UTC midnight. Treat DOB as a calendar date when prefilling individual registration, collecting it when missing so waiver eligibility can be evaluated.
  • height is an integer count of inches, not formatted feet and inches. weight is integer pounds.

Errors:

  • 401: Missing/invalid session token
  • 403: 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 PLAYER account 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; gender is MALE or FEMALE; and position is PG, SG, SF, PF, C, G, F, or GF.
  • The profile field ranges are: height 36–96, weight 50–400, graduation year 2000–2050, and jersey number 0–99. Send dateOfBirth as YYYY-MM-DD.
  • Only the listed fields are persisted. Additional request keys are ignored.

Errors:

  • 400: Invalid name, email, DOB, or profile field
  • 401: Missing/invalid session token
  • 403: Caller is not a parent
  • 422: Email already belongs to an account
  • 429: 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 field
  • 401: Missing/invalid session token
  • 403: Caller is not a parent
  • 404: 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"
    }
  ]
}
  • rosterCount counts active roster entries; rosterDeadline is null when the event has none.
  • Results are newest-first and capped at 200 rows.

Errors:

  • 401: Missing/invalid session token
  • 403: 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
    }
  ]
}
  • status is PENDING, APPROVED, DECLINED, or WITHDRAWN; 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 token
  • 403: 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
    }
  ]
}
  • eventTeamId is null when no roster entry links the registration; use it to drive the per-player roster endpoints in §5.8 through §5.11.
  • teamId and teamName are null for teamless individual registrations.
  • waiverSigned records 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 token
  • 403: 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 userId is the signed-in account are returned.
  • Build the buyer's purchase/registration history from this list. Use GET /me/registrations separately for linked-player approval, payment, and waiver actions.
  • A completed SPECTATOR order appears in this list. The API has no QR, scan, pass-token, or check-in redemption endpoint.

Status Codes:

  • 200: Orders returned
  • 400: Invalid pagination
  • 401: 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 returned
  • 400: Invalid user UUID
  • 401: Missing or invalid session
  • 403: The requested id is not the signed-in user
  • 404: User not found
  • 500: 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 updated
  • 400: Invalid user UUID or invalid body
  • 401: Missing or invalid session
  • 403: The requested id is not the signed-in user
  • 404: User not found
  • 409: 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 error
  • 401: Not authenticated
  • 403: Authenticated but not authorized for the role, account relationship, team, player, or requested action
  • 404: Not found
  • 409: Conflict, including sign-up with an existing email ("User with this email already exists") and the registration conflicts listed in §5
  • 410: Gone (roster invite revoked/expired; see §5.7)
  • 422: Unprocessable (roster deadline passed, ineligible account; see §5.7)
  • 429: Rate limited
  • 500: 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).