---
name: playerping-api
description: >
  Operate PlayerPing via the public REST API (players, events, clubs, teams, tournaments, RSVPs) using an
  API key. Use when the user asks to list/create/update/delete players, events, or teams,
  list/create clubs, invite players to events, change RSVP status, promote waitlist, or otherwise
  interact with PlayerPing programmatically.
---

# PlayerPing Public API

You are operating against **PlayerPing**, a **tennis** tournament and interclub organizer product.
Use only the public API under `/api/v1` with a Bearer API key. Do not use
session cookies, admin routes, or invent endpoints.

**Sport on writes:** `sport` must be `Tennis` (case-insensitive) when creating or updating players, clubs, teams, tournaments, and events. Other values return `400`. List/get responses may still include legacy sport strings on old rows until data is migrated.

## Public skill URL (no registration)

Anyone can fetch this skill without creating an account:

```
https://playerping.me/skills/playerping-api/SKILL.md
```

Human-readable mirror: https://playerping.me/docs/agent-skill

Calling the API still requires an organizer `pp_live_…` API key (Settings → API Keys).

## Prerequisites

1. **API key** — User provides `PLAYERPING_API_KEY` (or pastes a `pp_live_…` key).
   - Prefer an environment variable / secret store over repeating the key in chat.
   - Never commit keys, log them, or put them in artifacts/PR bodies.
2. **Base URL** (default production):
   ```
   https://playerping.me/api/v1
   ```
   Override only if the user gives another host (preview/local).

## Auth on every request

```http
Authorization: Bearer <PLAYERPING_API_KEY>
Content-Type: application/json
```

Missing/invalid key → `401`. Keys cannot perform admin actions.

## Safety rules

- **Default `notify: false`** when adding players to events. Only set `notify: true` (or call `fill/execute`) when the user explicitly wants SMS/email/WhatsApp invitations sent (this can spend credits).
- **Confirm before** deleting players/events or sending notifications (`notify: true` / `fill/execute` with `approved: true`).
- **Credit preflight:** call `GET /credits` or `GET /me` before any send. Do not execute when `estimatedSmsCredits > credits`.
- **Do not** create disposable junk data on a production account unless asked; clean up smoke-test resources you create.
- Magic-link `token` fields are never returned; do not ask for them or invent RSVP URLs.
- Treat phone numbers and emails as PII — summarize in replies when possible.

## curl helper

```bash
export PLAYERPING_API_KEY='pp_live_…'
export PLAYERPING_API='https://playerping.me/api/v1'
alias pp='curl -sS -H "Authorization: Bearer $PLAYERPING_API_KEY" -H "Content-Type: application/json"'
```

Errors are JSON: `{ "error": "message" }` with status `400|401|402|403|404|500`.

---

## Account & credits

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/me` | Account snapshot (`id`, `email`, `name`, `credits`, `smsEnabled`, `emailEnabled`) |
| GET | `/credits` | Credit balance + notification flags (preflight) |

```bash
pp "$PLAYERPING_API/me"
pp "$PLAYERPING_API/credits"
```

SMS/WhatsApp invitations cost **1 credit** each when SMS is enabled on the account; email is free. Insufficient credits → `402` on send.

---

## Players

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/players` | List roster |
| GET | `/players/stats` | Attendance aggregates (memory for fill ranking) |
| POST | `/players` | Create |
| GET | `/players/:id` | Get one |
| PATCH | `/players/:id` | Update |
| DELETE | `/players/:id` | Delete |

### Create / update body

```json
{
  "name": "Alex Rivera",
  "phone": "+15551234567",
  "gender": "M",
  "sport": "Tennis",
  "email": "alex@example.com",
  "clubId": null
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | yes | string |
| `phone` | yes | E.164, e.g. `+64211234567` |
| `gender` | yes | `M` \| `F` \| `OTHER` |
| `sport` | yes | must be `Tennis` (writes only) |
| `email` | no | omit to leave unchanged on PATCH; `null` clears |
| `clubId` | no | must be accessible to the key owner; omit to leave unchanged on PATCH; `null` clears |
| `wtnTennisId` / `wtnProfileUrl` | no | PATCH only, tennis players — link ITF World Tennis Number (URL or id); ratings filled server-side |
| `wtnSingles`, `wtnDoubles`, `wtnProfileUrl`, `wtnUpdatedAt`, `tnzNationalId`, `wtnDirectoryClubName` | — | read-only on GET when linked (lower WTN = stronger; numbers, not strings) |

### Attendance stats (`GET /players/stats`)

Returns `{ "stats": [ { playerId, playerName, eventsInvited, eventsAttended, eventsDeclined, eventsPending, attendanceRate, currentStreak, lastEventDate } ] }`.

`UNAVAILABLE` responses are excluded from Invited / Declined / Rate / streak (same rules as the app Attendance Stats).

### Examples

```bash
pp "$PLAYERPING_API/players"
pp "$PLAYERPING_API/players/stats"
pp -X POST "$PLAYERPING_API/players" -d '{"name":"Alex","phone":"+15551234567","gender":"M","sport":"Tennis"}'
pp -X PATCH "$PLAYERPING_API/players/PLAYER_ID" -d '{"name":"Alex R","phone":"+15551234567","gender":"M","sport":"Tennis"}'
pp -X DELETE "$PLAYERPING_API/players/PLAYER_ID"
```

Omitting `email` / `clubId` on PATCH preserves existing values (so the rename example above will not wipe them).

---

## Clubs

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/clubs` | List public platform clubs + your private clubs (`?sport=` optional) |
| POST | `/clubs` | Create a private club (only visible to the key owner) |

### Create body

```json
{
  "name": "Tuesday Night Crew",
  "sport": "Tennis",
  "location": "Central Courts",
  "countryCode": "NZ",
  "city": "Auckland"
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | yes | string |
| `sport` | yes | must be `Tennis` (writes only) |
| `location` | no | |
| `countryCode` | no | ISO country code (must exist) |
| `city` | no | |

API-created clubs are **private** (`userId` = key owner). Public/platform clubs (`userId` null, admin-maintained) appear in `GET /clubs` and can be used as `clubId` on players/events, but cannot be created via the API.

### Examples

```bash
pp "$PLAYERPING_API/clubs"
pp "$PLAYERPING_API/clubs?sport=Tennis"
pp -X POST "$PLAYERPING_API/clubs" -d '{"name":"Tuesday Crew","sport":"Tennis","location":"Central Courts"}'
```

---

## Teams

Teams are organizer-owned invite rosters. Members may come from different clubs. A player may belong to more than one team.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/teams` | List your teams (`?sport=` optional) |
| POST | `/teams` | Create |
| GET | `/teams/:id` | Get one with members |
| PATCH | `/teams/:id` | Update name, sport, or members |
| DELETE | `/teams/:id` | Delete (attached events keep running with `teamId` null) |

### Create / update body

```json
{
  "name": "Saturday Mixed",
  "sport": "Tennis",
  "members": [
    { "playerId": "cuid1", "kind": "BASE", "rank": 1 },
    { "playerId": "cuid2", "kind": "RING_IN", "rank": 2 }
  ]
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | yes on create | unique per account |
| `sport` | yes on create | members must match this sport |
| `members` | no | full replace set on PATCH; omit to leave membership unchanged. `kind` is `BASE` or `RING_IN` (default `BASE`); `rank` defaults to array order. **Rank is meaningful within each `kind` + player gender (`M` or `F`) pool** — two members may share the same `rank` when their genders differ. Players with gender `OTHER` are not ranked (`rank` 0). |
| `playerIds` | no | convenience full replace; remaining players keep their kind, new players default to `BASE`, rank follows array order within each kind + gender pool. Ignored if `members` is sent |

### Examples

```bash
pp "$PLAYERPING_API/teams"
pp "$PLAYERPING_API/teams?sport=Tennis"
pp -X POST "$PLAYERPING_API/teams" -d '{"name":"Saturday Mixed","sport":"Tennis","playerIds":["PLAYER_ID"]}'
pp -X PATCH "$PLAYERPING_API/teams/TEAM_ID" -d '{"playerIds":["PLAYER_ID"]}'
pp -X DELETE "$PLAYERPING_API/teams/TEAM_ID"
```

---

## Tournaments

Tournaments group events for combined attendance and rating results. Events stay independent unless `tournamentId` is set. A team can play events in different tournaments, or in none.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/tournaments` | List your tournaments (`?sport=` optional) |
| POST | `/tournaments` | Create |
| GET | `/tournaments/:id` | Get with events and aggregated `results` |
| PATCH | `/tournaments/:id` | Update name, sport, or description |
| DELETE | `/tournaments/:id` | Delete (attached events keep running with `tournamentId` null) |

### Create / update body

```json
{
  "name": "Spring Cup",
  "sport": "Tennis",
  "description": "Saturday mixed league"
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | yes on create | unique per account |
| `sport` | yes on create | attached events must match this sport |
| `description` | no | optional; `null` on PATCH clears |

`GET /tournaments/:id` includes `events` (with `responseCount`) and `results` (event/player/team totals, attendance, organizer note counts).

### Examples

```bash
pp "$PLAYERPING_API/tournaments"
pp "$PLAYERPING_API/tournaments?sport=Tennis"
pp -X POST "$PLAYERPING_API/tournaments" -d '{"name":"Spring Cup","sport":"Tennis"}'
pp "$PLAYERPING_API/tournaments/TOURNAMENT_ID"
pp -X PATCH "$PLAYERPING_API/tournaments/TOURNAMENT_ID" -d '{"description":"Week 1-8"}'
pp -X DELETE "$PLAYERPING_API/tournaments/TOURNAMENT_ID"
```

---

## Events

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/events` | List non-archived (`?archived=true` to include archived) |
| POST | `/events` | Create |
| GET | `/events/:id` | Get + responses (no tokens) |
| PATCH | `/events/:id` | Update |
| DELETE | `/events/:id` | Delete event and responses |

### Create body

```json
{
  "date": "2026-09-01",
  "time": "18:00",
  "location": "Central Courts",
  "sport": "Tennis",
  "requiredPlayers": { "M": 2, "F": 2 },
  "allowProposeTime": false,
  "clubId": null,
  "teamId": null,
  "tournamentId": null
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `date` | yes | ISO date / datetime |
| `time` | yes | e.g. `18:00` |
| `location` | yes | |
| `sport` | yes | |
| `requiredPlayers` | yes | object, typically gender → count |
| `allowProposeTime` | no | boolean |
| `clubId` | no | |
| `teamId` | no | owned team whose sport matches the event; when set, adding players is limited to that roster |
| `tournamentId` | no | owned tournament whose sport matches the event; omit for a standalone event |
| `recurrence` | no | `{ "frequency": "weekly"\|"biweekly"\|"monthly", "endDate"?: "ISO", "count"?: 1-52 }` |

### Patch fields

Partial update: `date`, `time`, `location`, `sport`, `status` (`OPEN`\|`PENDING`\|`CONFIRMED`\|`CANCELLED`), `requiredPlayers`, `allowProposeTime`, `archived` (past events only), `clubId`, `teamId`, `tournamentId`.

### Examples

```bash
pp "$PLAYERPING_API/events"
pp -X POST "$PLAYERPING_API/events" -d '{"date":"2026-09-01","time":"18:00","location":"Central Courts","sport":"Tennis","requiredPlayers":{"M":2,"F":2}}'
pp "$PLAYERPING_API/events/EVENT_ID"
pp -X PATCH "$PLAYERPING_API/events/EVENT_ID" -d '{"status":"CONFIRMED"}'
pp -X DELETE "$PLAYERPING_API/events/EVENT_ID"
```

---

## Fill this event (plan → approve → send)

Shared with the in-app **Fill this event** harness. Plan never spends credits; execute / `notify: true` only after explicit human confirmation.

| Method | Path | Purpose |
|--------|------|---------|
| POST | `/events/:id/fill/plan` | Return a `FillPlan` (gaps, ordered steps, credit estimate) — **never sends** |
| POST | `/events/:id/fill/execute` | Send invites only when `approved: true` |
| POST | `/events/:id/fill/verify` | Deterministic post-condition checks (quotas, credits, exclusions) |

### Plan body

```json
{ "additionalInfo": "prefer reliable doubles partners", "useAi": true }
```

- `additionalInfo` — optional hint for ranking.
- `useAi` — default `true` when the server has an AI key; set `false` to rank by ratings only (also used when AI is unavailable).

Response includes `plan`, plus `insufficientCredits` / `canAfford` for preflight. Rate-limited (`429` when exceeded).

### Execute body (approve-gated)

```json
{
  "playerIds": ["cuid1", "cuid2"],
  "approved": true,
  "planId": "optional-from-plan"
}
```

- **`approved` must be `true`** or the request is refused (`400`). Never invent approval.
- Optional `planId` must match `buildFillPlanId(eventId, playerIds)` from the plan (or omit it).
- Double-submit of the same `planId` within a short window returns `409` (idempotent approve).
- Equivalent send path: `POST /events/:id/players` with `{ "playerIds": [...], "notify": true }` after human confirmation.

### Verify body (optional)

```json
{
  "invitedPlayerIds": ["cuid1"],
  "notify": true,
  "creditsBefore": 10,
  "creditsUsed": 1,
  "creditsAfter": 9,
  "creditsEstimate": 1
}
```

Returns `{ "result": { "ok", "checks", "gapsRemaining", "rosterFull" } }`. Success is DB truth — not “the model said it worked.”

### Examples

```bash
pp "$PLAYERPING_API/credits"
pp -X POST "$PLAYERPING_API/events/EVENT_ID/fill/plan" -d '{"useAi":false}'
# Stop and confirm with the human, then:
pp -X POST "$PLAYERPING_API/events/EVENT_ID/fill/execute" -d '{"playerIds":["PLAYER_ID"],"approved":true,"planId":"PLAN_ID"}'
pp -X POST "$PLAYERPING_API/events/EVENT_ID/fill/verify" -d '{"invitedPlayerIds":["PLAYER_ID"],"notify":true}'
```

---

## Players on an event (RSVPs)

Responses link a player to an event. Status values: `PENDING`, `YES`, `NO`, `MAYBE`, `WAITLIST`, `UNAVAILABLE`.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/events/:id/players` | List responses |
| POST | `/events/:id/players` | Add players |
| PATCH | `/events/:id/players/:playerId` | Set status |
| DELETE | `/events/:id/players/:playerId` | Remove from event |
| POST | `/events/:id/waitlist/:responseId/promote` | Waitlist → `YES` |

### Add players body

```json
{
  "playerIds": ["cuid1", "cuid2"],
  "notify": false,
  "status": "PENDING"
}
```

- `playerIds` — required non-empty array of player IDs owned by the key user.
- `notify` — default behavior in this skill: **false**. `true` sends invitations per account notification settings; SMS/WhatsApp cost 1 credit each; email is free; insufficient credits → `402`.
- `status` — only when `notify` is false. Defaults to `PENDING`.

### Update status body

```json
{ "status": "YES" }
```

### Examples

```bash
pp "$PLAYERPING_API/events/EVENT_ID/players"
pp -X POST "$PLAYERPING_API/events/EVENT_ID/players" -d '{"playerIds":["PLAYER_ID"],"notify":false}'
pp -X PATCH "$PLAYERPING_API/events/EVENT_ID/players/PLAYER_ID" -d '{"status":"YES"}'
pp -X DELETE "$PLAYERPING_API/events/EVENT_ID/players/PLAYER_ID"
pp -X POST "$PLAYERPING_API/events/EVENT_ID/waitlist/RESPONSE_ID/promote"
```

---

## Common workflows

### Discover roster, clubs, teams, and upcoming events

1. `GET /players` — note IDs and sports.
2. `GET /clubs` — note public and private club IDs for the sport.
3. `GET /teams` — note invite-roster IDs (`?sport=` optional).
4. `GET /tournaments` — note competition IDs (`?sport=` optional).
5. `GET /events` — note IDs, dates, response counts.
6. `GET /events/:id` or `GET /events/:id/players` for RSVP detail.

### Create a private club and assign to a player

1. `POST /clubs` with `name` and `sport`.
2. `POST /players` (or `PATCH /players/:id`) with the returned `clubId`.

### Create a mixed-club team and attach it to an event

1. `POST /teams` with `name`, `sport`, and `playerIds`.
2. `POST /events` with matching `sport` and the returned `teamId`.
3. `POST /events/:id/players` only with those team members (`notify: false` by default).

### Create a tournament and attach events

1. `POST /tournaments` with `name` and `sport`.
2. `POST /events` with matching `sport` and the returned `tournamentId` (and optional `teamId`).
3. `GET /tournaments/:id` for events plus aggregated `results`.

### Schedule a new session and add players (silent)

1. `POST /events` with date/time/location/sport/`requiredPlayers`.
2. Resolve player IDs via `GET /players` (match by name carefully; ask if ambiguous).
3. `POST /events/:id/players` with `notify: false`.
4. Optionally `PATCH .../players/:playerId` to set `YES` if the organizer already knows attendance.

### Pre-mark a player unavailable (no invitation)

1. Resolve the player ID via `GET /players`.
2. `POST /events/:id/players` with `{ "playerIds": ["PLAYER_ID"], "notify": false, "status": "UNAVAILABLE" }`.
3. Confirm via `GET /events/:id/players` — status should be `UNAVAILABLE`; no credits used. Does not count toward Attendance Stats Invited/Declined/Rate.

### Invite with notifications (costs credits)

1. Confirm with the user that SMS/WhatsApp may spend credits.
2. `POST /events/:id/players` with `{ "playerIds": [...], "notify": true }`.
3. Report `notificationsSent`, `creditsUsed`, `creditsRemaining` from the response.

### Fill an event (plan → human confirms → execute)

Use this when the organizer wants to fill remaining gender quotas. Prefer the shared fill endpoints; fall back to manual ranking only if plan fails.

1. `GET /credits` (or `GET /me`) — note balance and whether SMS is enabled.
2. Optional memory: `GET /players/stats` for attendance rates / decline streaks.
3. `POST /events/:id/fill/plan` — get ordered `steps`, `gaps`, `estimatedSmsCredits`, `planId`. If `insufficientCredits` is true, stop and tell the organizer.
4. Show the organizer the plan (names, genders, reasons, credit estimate). **Stop and confirm** — do not auto-SMS.
5. Optional silent exclusions: `POST .../players` with `notify: false`, `status: "UNAVAILABLE"` only when the organizer asks.
6. After approval: either
   - `POST /events/:id/fill/execute` with `{ "playerIds": [...], "approved": true, "planId": "..." }`, or
   - `POST /events/:id/players` with `{ "playerIds": [...], "notify": true }`.
7. `POST /events/:id/fill/verify` with invite + credit fields from the execute response (and/or re-fetch `GET /events/:id/players`). Async RSVPs mean “invites sent” ≠ “roster full.”
8. Repeat plan → confirm → execute until quotas are met or the organizer stops.

In-app organizers use the same loop via **Fill this event** on the event page.

### Fix RSVPs / waitlist

1. `GET /events/:id/players` — find `WAITLIST` entries and their response `id`.
2. Promote: `POST /events/:id/waitlist/:responseId/promote`, or set status via PATCH.

---

## Out of scope (v1)

Do not call these via this skill: club update/delete, ratings write, chat, time proposals, session-only AI routes (`/api/ai/...`), Stripe/credits purchase, Settings/API-key management (browser session only), admin APIs, event club/team broadcasts, off-event team announcements, chase/cron auto-SMS, or unsupervised multi-round sends.

API keys are created/revoked by the human in **Settings → API Keys**. Changing the account password revokes all keys. Point them to https://playerping.me/docs/api for human docs.

## Response habits

- Prefer concise summaries (counts, names, IDs, statuses) over dumping full JSON.
- On errors, surface the HTTP status and `error` message, then suggest a fix (e.g. E.164 phone, missing fields, revoke/recreate key).
- After mutating, re-fetch or echo the returned resource so the user can verify.
