# GigOrganizer API Reference

Selected routes covering the full gig lifecycle. **Most** are token-accessible via
`Authorization: Bearer go_pro_*` and are tagged **[read]** or **[full]** below. A few
entries are documented for reference but tagged **[session-only — dashboard]** (e.g.
sending for e-signature, requesting W-9s, filing 1099s, managing API tokens): those are
**NOT** token-callable — a bearer-token call returns 403, and the action must be done from
the GigOrganizer dashboard. **Rule of thumb: every route in this reference is token-callable
EXCEPT the ones tagged [session-only — dashboard].** The **[read]**/**[full]** tags mark the
minimum tier where it matters (read-scope vs full-scope); a route shown without a tier tag is
still token-accessible — do NOT treat an untagged heading as off-limits.

**Base URL:** `https://gigorganizer.com`

**Auth:** Token-accessible requests (the **[read]**/**[full]** routes) must include
`Authorization: Bearer go_pro_YOUR_TOKEN`. **[session-only — dashboard]** entries cannot
use a token at all.

**Rate limiting:** 100 requests per minute per token. Returns 429 with `Retry-After` header.

**Important:** API token requests use `getAdminClient()` internally — `auth.uid()` is NULL.
Route handlers filter by the resolved `user_id` from the token. Response shapes are identical
to cookie-authenticated sessions.

**Casing convention:** Request bodies use **camelCase**. Responses use camelCase for
top-level keys but may include snake_case for nested DB-sourced objects (e.g., `contract_musicians`
rows in GET responses).

---

## Tiered Access

API tokens have two scopes controlled by subscription tier:

| Tier | Scope | Access |
|------|-------|--------|
| Free | none | No token access |
| Monthly Pro | `read` or `full` | Token scope (not tier) gates access — both tiers may hold a full token |
| Annual Pro | `read` or `full` | Same as Monthly Pro — equal API/agent access |

Routes are annotated below with **[read]** (read-scope token) or **[full]** (full-scope token). Full scope is available to any paid Pro tier (Monthly or Annual); the gate is the token's scope, not the subscription tier.
Some entries are annotated **[session-only — dashboard]**: those are documented for reference
but are NOT token-accessible (a bearer call 403s) — they're dashboard actions. Any route not
listed here at all is likewise session-only and cannot be accessed via API tokens.

### GET /api/user/tier [read]

Check authentication status and access level. **Call this first.**

**Response (token auth):**
```json
{ "authenticated": true, "tier": "pro_monthly", "scope": "read", "authMode": "token" }
```

**Response (no auth):**
```json
{ "authenticated": false, "tier": null, "scope": null, "authMode": "none" }
```

Headers: `Cache-Control: private, no-store`, `Vary: Authorization, Cookie`

### Scope Violation Response (403)

When a read-scope token attempts a full-scope action:
```json
{
  "error": "This action requires a full-access token.",
  "tier": "pro_monthly",
  "scope": "read",
  "upgradeUrl": "https://gigorganizer.com/dashboard/settings/api",
  "dashboardUrl": "https://gigorganizer.com/dashboard/gigs?select={id}&filter=upcoming"
}
```

---

## Credits (GigPacks)

### GET /api/credits [read]

Check GigPack balance (subscription allowance + purchased packs).

**Response:**
```json
{
  "total": 8,
  "subscription": {
    "remaining": 5,
    "allocated": 10,
    "cap": 20,
    "periodEnd": "ISO8601",
    "plan": "pro_monthly",
    "unlimited": false,
    "capReached": false
  },
  "packs": [{ "id": "uuid", "size": 10, "remaining": 3 }],
  "packTotal": 3,
  "giftedTotal": 0,
  "access": {
    "canCreateContract": true,
    "canBuyGigPacks": true,
    "subscriptionState": "limited",
    "displayBalance": { "kind": "numeric", "total": 8, "label": "8" }
  }
}
```

`subscription` is `null` when the user has no active subscription (pack-only credits). For decision-making, prefer `access.canCreateContract` and `access.canBuyGigPacks` over inferring state from `total`. Annual Pro may be `subscription.unlimited: true` even when `total` is a finite internal counter.

### POST /api/credits/use [full]

Use one GigPack credit to create a contract (no Stripe payment required).

**Body:** Same fields as `POST /api/checkout/esign`:
```json
{
  "contractTitle": "string",
  "performer": { "name": "string", "email": "string" },
  "client": { "name": "string", "email": "string" },
  "formData": { ... },
  "performerType": "musician",
  "eventDate": "YYYY-MM-DD"
}
```

Canonical `formData` shape for no deposit required:
```json
{
  "depositMode": "percent",
  "depositPercent": "0",
  "depositFixedAmount": "",
  "depositDueDate": ""
}
```

**Response:**
```json
{ "success": true, "purchaseId": "uuid", "draftId": "string", "creditSource": "subscription | pack | gift" }
```

The `purchaseId` is a UUID that works as the `session_id` parameter in `/api/purchase/session/[session_id]/*` routes.

For institutional contracts where the legal client and signer differ, include `formData.signatoryName`, `formData.signatoryEmail`, and optionally `formData.signatoryPhone`. The route bootstraps the designated signatory share so the first send routes to that signer.

**Errors (stable):**
- `402` — No GigPacks available
- `409` — Credit consumed by another concurrent request
- `429` — Rate limited (12 attempts per 10 minutes). Response includes `limit`, `remaining`, `resetAt` fields plus `Retry-After` header.

---

## Leads

### POST /api/leads [full]

Create an agent-extracted lead without running the parser cascade. Useful when an
agent has already read email/SMS history and has structured fields.

**Body:** `name` and `email` required. Optional fields include `phone`,
`eventType`, `eventDate`, `eventTime`, `eventDurationHours`, `venue`,
`guestCount`, `budget`, `quote`, `depositAmountCents`, `message`, `notes`,
`selectedPerformerNameId`, `sourceType`, `referralSource`, `sourceUrl`,
`currentStage`, `isTest`, and `communications`.

`sourceType` must be one of `form`, `email_import`, `email_forward`, `manual`,
`sms_import`, or `paste`. `referralSource` is a human credit/source label such as
the colleague who referred the client. `sourceUrl` is for web attribution and must
be `http` or `https`.

### PATCH /api/leads/{id} [full]

Update an existing lead. Supports the same event/client editing fields used by the
dashboard, plus `referralSource` and `sourceUrl` for source hygiene. Empty strings
or `null` clear nullable fields.

### POST /api/leads/forms [full]

Create a lead-capture form. One call can create + website-style-match + attach a logo,
so an agent can stand up a styled, on-brand form in a single request (see
`docs/plans/agent-booking-presence-setup.md`). (MCP: `create_lead_form` action=create;
`update` wraps `PATCH /api/leads/forms/{id}`. `create_lead_form` is now a thin
backward-compat alias that delegates to the same shared handlers as the richer
`manage_lead_form` tool — see below.)

**Body:** all fields optional. `name` (defaults to "Contact Form"), `slug` (auto-derived
from name, collision-retried), `fields`, `eventTypes`, `heading`, `description`,
`thankYouMessage`, `accentColor`, `buttonText` (custom CTA label, ≤40 chars, defaults
"Send Inquiry"), `performerNameId` (owner-scoped), and the auto-response
fields `autoResponseSubject` / `autoResponseBody`.

**Pro website-matching fields (Pro only):**
- `computedStyles` — a `getComputedStyle` snapshot, converted by the same deterministic
  mapper the preview endpoint uses, then sanitized to the bounded branding vocabulary.
- `branding` — a candidate branding object (same bounded schema). `computedStyles` takes
  precedence when both are sent. A non-object value returns `400`. **`branding.logoUrl` is
  ignored** — it is not settable via inline branding; use the `logoUrl` field below.
- `logoUrl` — an `https` image URL (PNG/JPEG, ≤2048-char URL, ≤500KB). Fetched, validated,
  SSRF-guarded, resized server-side, and attached as the form's logo. **Best-effort:** a
  logo failure does NOT fail form creation — the response includes a `logoError` string and
  the form is still returned.

**Pro gating is a field-level strip, not a 403:** a non-Pro caller still gets a working
form created; the Pro-only fields (branding/computedStyles/logo/auto-response) are silently
dropped. See `lead-form-appearance.md` for the bounded branding schema and
the CSS→field mapping table.

**Response:** `201` with `{ "form": {...} }`, or `{ "form": {...}, "logoError": "..." }` if
the optional logo attach failed.

### POST /api/leads/forms/appearance/preview [full]

Stateless validate/derive endpoint for lead form appearance. Pro-gated. Accepts either
a `{ "computedStyles": <getComputedStyle snapshot> }` or `{ "branding": <candidate> }`
body and returns `{ "branding": {...} }` containing only valid, bounded values — nothing
is saved. Use this to normalize CSS-derived values before PATCHing
`PATCH /api/leads/forms/{id}`. See `lead-form-appearance.md` for the
complete schema, CSS→field mapping table, and recommended agent workflow.

### MCP tool: `manage_lead_form` (no new routes)

The richer companion to `create_lead_form` — a single multi-action MCP tool that wraps
the lead-form routes above plus three **pure constructor** actions that hit NO route and
write NOTHING. It does not add any new HTTP surface; it reuses
`GET/POST /api/leads/forms`, `GET/PATCH /api/leads/forms/{id}`, and
`POST /api/leads/forms/appearance/preview`. Actions:

- `list` / `get` — read forms (`GET /api/leads/forms`, `GET /api/leads/forms/{id}`).
- `create` / `update` — delegate to the shared create/update handlers (same as
  `create_lead_form`). `update` covers name/fields/copy/event types only — NOT appearance.
- `preview_appearance` — wraps `POST /api/leads/forms/appearance/preview` (Pro).
- `update_appearance` — wraps the `PATCH /api/leads/forms/{id}` **branding merge** to restyle
  an existing form. Inputs (one of): `branding`, `computed_styles` (mapped to the bounded
  branding vocab before the PATCH — NOT a pass-through), `site_theme`
  (`artful`/`aspen`/`walter_2`), or `match_host`. `site_theme` defaults `match_host:true`
  (presets cover field/button style, not page typography).
- `get_embed_code` — pure constructor: returns the direct `/inquire/{slug}` URL, an iframe
  snippet, and a JS embed snippet from a form slug. No route, no write.
- `convert_external_form` — pure constructor: maps ANY source form's fields onto
  GigOrganizer's FIXED field vocabulary and reports `unsupportedFields`. Input is
  `source_format=formloom` (`formloom_items` `{item_1:"Name", …}` shorthand) OR
  `source_format=normalized` (`source_fields` `[{label,type?,required?,options?}]`), plus
  optional `form_meta`. Formloom is one adapter; the field set is fixed, so the converter
  generalizes to any source.
- `dry_run_migration` — pure + read-only: composes `convert_external_form` + `get_embed_code`
  and surfaces Pro-styling / active-form-limit warnings. Optionally takes a `form_id` to GET
  an existing GO form to compare against. **Writes nothing** — the safe way to preview a
  migration before anything goes live. (Migrating multiple forms needs Pro: full branding +
  >1 active form.)

Free for setup (the free-tier MCP carve-out): write actions pass `{ freeAllowed: true }` and
the route field-strips full Pro branding. Pure read/construct actions need auth only.

---

## Historical Imports

### POST /api/imports/historical [full]

Validate, plan, or stage a historical gig import payload. `mode=validate`
performs validation only, `mode=plan` returns ordered operations, and
`mode=stage` persists the plan without touching live client, venue, team, or gig
records.

Validation and plan responses always advertise executable support with
`importExecutionSupported: true` / `executionSupported: true`. This is a static
capability flag indicating stage/apply is available; check `canApply` (not
`executionSupported`) to determine whether a specific payload can be applied.
Staged batches therefore contain ordered apply operations that can be preflighted
or applied through the batch apply endpoint below.

### GET /api/imports/historical [full]

List staged historical import batches, or pass `batchId` to inspect one batch and
its staged operations.

### POST /api/imports/historical/{batchId}/apply [full]

Apply a staged historical import batch. Use `?mode=preflight`, `?dryRun=1`, or
body `{ "dryRun": true }` to run the readiness gate without writing records.

Apply is idempotent through source mappings. It creates or updates saved clients,
venues, and team members, creates historical paid/cancelled gig records, attaches
historical team rows without marking anyone holding, and stores future gigs in a
hold table for later active-gig migration.

Historical gig rows may include `performerName` to preserve performer/ensemble
identity separately from the event `title`. If omitted, apply uses the account's
default performer name, then the organizer profile name, before falling back to
`Imported performer`.

Response includes `readiness`, `operationCounts`, and source mappings for the
GigOrganizer rows created or reused.

---

## 1. Gig Overview

### GET /api/purchases

List all gigs (purchases) for the authenticated user.

**Response:**
```json
{
  "success": true,
  "purchases": [
    {
      "id": "uuid",
      "status": "completed",
      "email": "client@example.com",
      "createdAt": "ISO8601",
      "completedAt": "ISO8601 | null",
      "contract": {
        "id": "uuid",
        "title": "string",
        "event_date": "YYYY-MM-DD",
        "status": "string",
        "performer_name": "string",
        "client_name": "string",
        "client_email": "string",
        "form_data": {},
        "deposit_paid_at": "ISO8601 | null",
        "balance_paid_at": "ISO8601 | null",
        "contract_musicians": [],
        "envelopes": []
      }
    }
  ]
}
```

### GET /api/purchase/session/{sessionId}

Get details for a single gig (purchase + contract + envelope).

**Response:**
```json
{
  "success": true,
  "purchase": {
    "id": "uuid",
    "status": "completed",
    "email": "string",
    "createdAt": "ISO8601",
    "completedAt": "ISO8601 | null"
  },
  "contract": {
    "id": "uuid",
    "title": "string",
    "event_date": "YYYY-MM-DD",
    "performer_name": "string",
    "client_name": "string",
    "client_email": "string",
    "form_data": {},
    "deposit_paid_at": "ISO8601 | null",
    "balance_paid_at": "ISO8601 | null"
  },
  "envelope": {
    "id": "uuid",
    "status": "string",
    "performer_status": "string | null",
    "client_status": "string | null",
    "created_at": "ISO8601"
  }
}
```

`envelope` is `null` if no e-signature has been sent. All fields in `contract` and `envelope` use **snake_case** (raw DB rows).

---

## 2. E-Signature

### POST /api/purchase/session/{sessionId}/esign [session-only — dashboard]

Send contract for e-signature. **Not available via API token** — this route is
session-only (not in the token capability allowlist). Hand the user the purchase
page URL instead: `https://gigorganizer.com/purchase/session/{sessionId}`. You
can still read signing status via `GET .../esign/db-status`.

This PAT/agent exclusion is an explicit decision (Dan Fries, 2026-06-05): sending
a contract emails a client a legally-binding document, so personal access tokens
stay blocked from direct esign-send. Agents send contracts only via the curated
MCP `send_contract_to_client` tool path, never a raw token hitting this endpoint.

**Body:**
```json
{
  "embedded": false,
  "includeLogo": true,
  "customMessage": "string (optional, max 1000 chars)"
}
```

**Response:**
```json
{
  "success": true,
  "envelopeId": "uuid",
  "providerEnvelopeId": "string | null",
  "performerSignUrl": "string (only if embedded=true)",
  "clientEmailSent": true,
  "performerEmailDeferred": true,
  "emailWarning": "string (optional)"
}
```

### POST /api/purchase/session/{sessionId}/esign/status

Sync the envelope row with the external provider's current status. Mutates
state: writes envelope status / signed_pdf_url, may trigger counterpart
finalization and bumps `contracts.calendar_sequence`. Was `GET` before
2026-05-15; converted to `POST` because the handler was state-changing
(read-only consumers should use `GET /esign/db-status`). Token scope:
`FULL_SCOPE` (write).

**Response (envelope exists):**
```json
{
  "success": true,
  "envelope": {
    "id": "uuid",
    "status": "pending | sent | viewed | partially_signed | signed | completed | voided | declined | expired",
    "performerStatus": "pending | signed",
    "clientStatus": "pending | signed",
    "signedPdfUrl": "string | null",
    "counterpartPdfPath": "string | null",
    "counterpartUploadedAt": "ISO8601 | null",
    "createdAt": "ISO8601",
    "clientLastRemindedAt": "ISO8601 | null",
    "performerLastRemindedAt": "ISO8601 | null",
    "provider": "inhouse | esignatures-com"
  }
}
```

**Response (no envelope):**
```json
{
  "success": true,
  "envelope": null,
  "message": "E-signature not sent yet"
}
```

### GET /api/purchase/session/{sessionId}/esign/db-status

Pure DB read of the envelope row — no external provider call, no state
change, no rate limiting. Use this for status polling from `READ_SCOPE`
tokens or any side-effect-free read path.

**Response (envelope exists):**
```json
{
  "envelope": {
    "id": "uuid",
    "status": "pending | sent | viewed | partially_signed | signed | completed | voided | declined | expired",
    "performerStatus": "pending | signed | null",
    "clientStatus": "pending | signed | null",
    "signedPdfUrl": "string | null",
    "createdAt": "ISO8601"
  }
}
```

**Response (no envelope):**
```json
{ "envelope": null }
```

**Response (contract missing):** 404 `{ "error": "Contract not found" }`

Note: the response is intentionally smaller than POST /esign/status — it
omits `counterpartPdfPath`, `counterpartUploadedAt`, `clientLastRemindedAt`,
`performerLastRemindedAt`, `provider`, and the top-level `success` flag.
Use POST /esign/status when you need those fields (and accept the state-
sync side effect).

### GET /api/purchase/session/{sessionId}/esign/download [session-only — dashboard/guest]

Download the signed contract PDF. **Not token-callable** — this route authenticates via a
session cookie or a guest-access cookie (`verifyGuestAccess`), not a bearer token; an
API-token request gets a 404. Direct the user to download from the dashboard (or their guest
link); an agent cannot fetch the PDF with `GIGORGANIZER_API_TOKEN`.

**Response:** Binary PDF with `Content-Type: application/pdf`

### POST /api/contracts/{contractId}/supersession/clear [full]

Clear an uploaded externally-signed replacement PDF and restore the prior
contract baseline. This is the JSON companion to the multipart supersession
upload route. The upload route is session/native-only; this clear route is
token-accessible for full-scope tokens on any paid Pro tier.

Because clearing a signed contract-of-record is destructive, API token callers
must include `confirm: true` after explicit user confirmation.

**Body:**
```json
{
  "confirm": true,
  "reason": "string (optional)"
}
```

**Response:**
```json
{
  "ok": true,
  "clearedRecordId": "uuid",
  "restoredKind": "counterpart_completed | inhouse_completed | provider_completed | external_gig_confirmed | none"
}
```

`restoredKind` is the baseline contract-of-record kind restored after the
supersession is cleared. Treat it as an informational string; do not use it as
the sole source of truth for current signing state.

**Errors (stable):**
- `400` — Invalid contract id
- `401` — Missing/invalid auth
- `403` — Not the contract owner or token scope is insufficient
- `404` — Contract not found
- `409` — `confirmation_required` when token/MCP callers omit `confirm: true`

---

## 3. Team Members

### GET /api/purchase/session/{sessionId}/musicians

List all team members on this gig.

**Response:**
```json
{
  "musicians": [
    {
      "id": "uuid",
      "musician_name": "string",
      "musician_email": "string | null",
      "musician_phone": "string | null",
      "pay_amount": 25000,
      "arrival_time": "16:30 | null",
      "hit_time": "string | null",
      "end_time": "string | null",
      "status": "not_contacted | invited | holding | available | confirmed | unavailable | tentative_no | released",
      "is_point_person": false,
      "selected": true,
      "team_member_type": "guitar | null",
      "confirmation_stage": "availability | final | null",
      "notes": "string | null",
      "created_at": "ISO8601"
    }
  ]
}
```

### POST /api/purchase/session/{sessionId}/musicians

Add a team member to a gig.

**Body (camelCase):**
```json
{
  "musicianName": "Mike Johnson",
  "musicianEmail": "mike@example.com",
  "musicianPhone": "555-0100",
  "payAmount": 25000,
  "arrivalTime": "16:30",
  "hitTime": "18:00",
  "endTime": "22:00",
  "status": "not_contacted",
  "isPointPerson": false,
  "selected": true,
  "teamMemberType": "guitar",
  "taxTeamMemberId": "uuid (optional, links to People directory)",
  "notes": "string (optional, max 2000)"
}
```

Required: `musicianName`. All other fields optional. New rows default to
`status: "not_contacted"` because this endpoint only adds the member; it does
not send an invite or record a response. Use `selected: true` for Assigned and
`selected: false` for Availability Pool. New rows must be created as
`not_contacted`; use the invite/confirmation flow or an explicit manual
confirmation update to advance those states truthfully.

**Response (201):**
```json
{
  "musician": { "...DB row (snake_case fields)..." },
  "linkWarning": "string (optional, if People directory link failed)"
}
```

### PATCH /api/purchase/session/{sessionId}/musicians/{id} [full]

Update a per-gig team member row. Full-scope API tokens can edit safe fields
such as placement, pay, contact details, notes, and deliberate manual status
changes.

Source route: `PATCH /api/purchase/session/[session_id]/musicians/[id]`.
`id` must be the per-gig `contract_musicians.id` UUID returned by
`GET /api/purchase/session/{sessionId}/musicians`. This route updates the gig
assignment row, not the saved People directory record.

**Body fields (all optional):**
```json
{
  "musicianName": "string",
  "musicianEmail": "string | null",
  "musicianPhone": "string | null",
  "selected": true,
  "payAmount": 80000,
  "arrivalTime": "4:30 PM",
  "hitTime": "5:30 PM",
  "endTime": "8:00 PM",
  "isPointPerson": false,
  "hasDayOfContactAccess": false,
  "teamMemberType": "guitar | null",
  "taxTeamMemberId": "uuid | null",
  "backup_for_musician_id": "uuid | null",
  "inviteDeferredUntil": "ISO8601 | null",
  "notes": "string | null",
  "status": "not_contacted | invited | holding | available | confirmed | unavailable | tentative_no | released"
}
```

Use `selected: true` for Assigned and `selected: false` for Availability Pool.
`backup_for_musician_id`, when non-null, must be another per-gig musician UUID on
the same contract and cannot point to the same row or create backup chains.

Manual response-like status changes require explicit confirmation metadata:

```json
{
  "status": "holding",
  "manualConfirmation": true,
  "manualContactMethod": "text",
  "manualConfirmationNote": "Confirmed by text with Dan"
}
```

`manualContactMethod` must be one of `text`, `phone`, `email`, `in_person`, or
`other`. `manualConfirmationNote`, when provided, must be a string up to 500
characters. Use manual status advancement only when the organizer has confirmed
outside GigOrganizer. The safer default is to call the invite endpoint so the
team member can respond and receive the calendar hold/confirmation.

If a member has never been contacted, response-like statuses (`holding`,
`available`, `confirmed`) require `manualConfirmation: true` plus a valid
`manualContactMethod`, even after detours through `released`, `unavailable`, or
`tentative_no`. Existing contacted members may still be advanced by status-only
clients for backward compatibility; supplied manual metadata is validated before
any database write.

**Response:**
```json
{
  "musician": { "...DB row (snake_case fields)..." },
  "calendarDirtied": true,
  "contactSync": { "synced": ["user_contacts"], "failed": [], "skipped": [] }
}
```

**Errors (stable):**
- `400` — invalid musician UUID, invalid backup UUID, invalid transition, invalid manual metadata, contacted member reset to `not_contacted`, or no fields to update
- `403` — read-scope token attempted this full-scope action
- `404` — gig or musician not found for this user
- `409` — status changed concurrently while saving

### POST /api/purchase/session/{sessionId}/musicians/invite

Send calendar invitations to team members.

**Body:**
```json
{
  "musicianIds": ["uuid1", "uuid2"],
  "customMessage": "string (optional)",
  "unconfirmedOnly": false,
  "forceHold": false
}
```

All fields optional. Without `musicianIds`, sends to all eligible team members.

**Response:**
```json
{
  "success": true,
  "sent": 3,
  "failed": 0,
  "total": 3,
  "results": [
    {
      "musician": "Mike Johnson",
      "email": "mike@example.com",
      "success": true,
      "error": null
    }
  ]
}
```

**Rate limit:** 5-minute cooldown between bulk invites for the same gig.

### POST /api/purchase/session/{sessionId}/musicians/{id}/release

Approve or deny a team member's release, or initiate an organizer release.

**Body:**
```json
{
  "action": "approve | deny | release",
  "customMessage": "string (optional, max 1000)"
}
```

- `approve`: Approve a team member's release request
- `deny`: Deny a release request (team member stays)
- `release`: Organizer-initiated release (no prior request needed)

**Response:**
```json
{
  "success": true,
  "action": "approved | denied | released",
  "status": "released (only for approve/release)"
}
```

### POST /api/purchase/session/{sessionId}/musicians/{id}/calendar-reassurance

Re-share calendar feed subscription links and the per-gig portal URL with a
confirmed team member. Sends a gig-context email with Google/webcal/HTTPS
subscribe links, a single-gig `.ics` attachment fallback, and a portal CTA.

Restricted to `status === "confirmed"` members on non-cancelled gigs. Atomic
60s cooldown via `calendar_sent_at` (shared with the invite endpoint).

**Required scope:** FULL_SCOPE (full-scope token; any paid Pro tier). Mutating
admin-backed route — `enforceTokenCapability()` allowlist.

**Path params:**
- `sessionId` — purchase session id
- `id` — `contract_musicians.id` (UUID)

**Body:** Empty.

**Response:**
```json
{
  "success": true,
  "queued": false,
  "sentTo": "member@example.com",
  "hasSubscription": true
}
```

- `hasSubscription: false` — sent without subscribe URLs (member has no
  `tax_team_members` row); only the single-gig ICS + portal CTA shipped.

**Errors:**
- `400` — member has no email, gig is cancelled, or member is not confirmed
- `429` — 60s cooldown active
- `500` — token resolution failure (cooldown is rolled back)
- `502` — email send failed (cooldown is rolled back)

---

## 4. Payment Tracking

### POST /api/purchase/session/{sessionId}/payment-status

Mark deposit or balance as paid/unpaid.

**Body:**
```json
{
  "type": "deposit | balance",
  "action": "mark_paid | mark_unpaid",
  "paidBy": "cash | venmo | zelle | paypal | check | bank_transfer | other",
  "paidAmount": 125000
}
```

Required: `type`, `action`. `paidBy` and `paidAmount` (cents) are optional.

**Response:**
```json
{
  "success": true,
  "depositPaidAt": "ISO8601 | null",
  "depositPaidBy": "cash | null",
  "depositPaidAmount": 125000,
  "balancePaidAt": "ISO8601 | null",
  "balancePaidBy": "string | null",
  "balancePaidAmount": null
}
```

---

## 5. Documents

### POST /api/purchase/session/{sessionId}/documents/send

Send a document to the client via email.

**Body:**
```json
{
  "documentType": "payment-schedule | deposit-invoice | balance-invoice | deposit-receipt | balance-receipt | full-receipt | contract-pdf",
  "customMessage": "string (optional)",
  "overrideRecipient": false,
  "recipientEmail": "alt@example.com (only when overrideRecipient=true)"
}
```

**Gates:**
| Type | Requires |
|------|----------|
| `deposit-receipt` | Deposit marked as paid |
| `balance-receipt` | Balance marked as paid |
| `full-receipt` | All payments marked as paid |
| Others | No gate |

**Response:**
```json
{
  "success": true,
  "messageId": "string | null",
  "queued": false
}
```

**Cooldown:** 60 seconds between duplicate sends.

---

## 6. Client Communication

### POST /api/purchase/session/{sessionId}/email

Send contract summary email to client with view/sign link.

**Body:**
```json
{
  "message": "string (optional custom message)"
}
```

**Response:**
```json
{
  "success": true,
  "messageId": "string",
  "sentTo": "client@example.com"
}
```

### POST /api/purchase/session/{sessionId}/nudge-client

Nudge client about outstanding items (signing, deposit, or both).

**Body:**
```json
{
  "customMessage": "string (optional, max 1000)"
}
```

**Response:**
```json
{
  "nudged": true,
  "missing": {
    "signing": true,
    "deposit": false
  }
}
```

### POST /api/purchase/session/{sessionId}/client-reminder

Send a pre-event reminder to the client.

**Body:**
```json
{
  "customMessage": "string (optional, max 1000)"
}
```

**Response:**
```json
{
  "success": true,
  "sentTo": "client@example.com"
}
```

### POST /api/purchase/session/{sessionId}/review-preference [full]

Suppress or clear future review requests for this gig's client contact. Requires
Annual Pro. This is the token-accessible path for importing a legacy
"do not ask again" preference when the preference is tied to a known gig/client.

**Body:**
```json
{
  "action": "suppress | clear",
  "reason": "manual | review_received"
}
```

**Response:**
```json
{
  "success": true,
  "contact": {
    "id": "uuid",
    "review_request_suppressed_at": "ISO8601 | null",
    "review_request_suppressed_reason": "manual | review_received | null"
  }
}
```

---

## 7. Client Portal Shares

### GET /api/purchase/session/{sessionId}/shares

List active portal shares for this gig.

### POST /api/purchase/session/{sessionId}/shares

Create a new client portal share (for planners, day-of coordinators, etc.).

**Body:**
```json
{
  "role": "planner | viewer",
  "visibilityPreset": "full_access | event_coordinator | day_of_only",
  "name": "string (optional)",
  "email": "string (optional)",
  "phone": "string (optional)",
  "isPrimaryContact": false,
  "primaryContactScope": "logistics | all_communications",
  "isPaymentContact": false,
  "isDesignatedSignatory": false,
  "visibilityOverrides": {}
}
```

**Response:**
```json
{
  "url": "https://contracts.gigorganizer.com/client/TOKEN",
  "shareId": "uuid",
  "token": "string",
  "role": "planner | viewer",
  "name": "string | null",
  "email": "string | null"
}
```

---

## 8. Notes & Tasks

> **Note:** Tasks routes use **snake_case** body params (exception to the general camelCase convention).

### GET /api/purchase/session/{sessionId}/notes

Get gig notes (directions, performance notes, private notes, venue library).

**Response (flat top-level):**
```json
{
  "specialDirections": "string | null",
  "performanceNotes": "string | null",
  "privateNotes": "string | null",
  "clientVenueInfo": "string | null",
  "venueName": "string | null",
  "venueAddress": "string | null",
  "venueDefaultDirections": "string | null",
  "portalOverrides": {},
  "contactPreFills": {},
  "userVenues": [{ "id": "uuid", "name": "string", "address": "string | null", "defaultDirections": "string | null" }]
}
```

### PATCH /api/purchase/session/{sessionId}/notes

Update gig notes. All fields optional.

**Body:**
```json
{
  "specialDirections": "string (max 2000)",
  "performanceNotes": "string (max 2000)",
  "privateNotes": "string",
  "saveAsVenueDefault": true,
  "venueName": "string",
  "venueAddress": "string"
}
```

**Response:**
```json
{
  "success": true,
  "contract": { "id": "uuid", "title": "string", "form_data": {} },
  "updated_fields": ["specialDirections", "privateNotes"],
  "message": "Notes updated"
}
```

### GET /api/purchase/session/{sessionId}/tasks

List prep tasks for this gig.

**Response:**
```json
{
  "tasks": [
    {
      "id": "uuid",
      "title": "Pick up mic stands",
      "completed": false,
      "completed_at": null,
      "sort_order": 0,
      "due_date": "YYYY-MM-DD | null",
      "is_sticky": false,
      "show_on_calendar": false,
      "task_type": "string | null",
      "created_at": "ISO8601"
    }
  ]
}
```

### POST /api/purchase/session/{sessionId}/tasks

Create a prep task.

**Body:**
```json
{
  "title": "string (required, 1-200 chars)",
  "due_date": "YYYY-MM-DD (optional)",
  "is_sticky": false,
  "show_on_calendar": false,
  "task_type": "string (optional)"
}
```

**Response (201):**
```json
{
  "task": { "id": "uuid", "title": "...", "completed": false, "..." : "..." }
}
```

---

## 9. Calendar Subscriptions

### GET /api/calendar/subscriptions

List active calendar feed subscriptions.

**Query params:** `feedType` or `feed_type` (optional filter: `go_user | team_member | client`)

**Response:**
```json
{
  "subscriptions": [
    {
      "id": "uuid",
      "feedType": "go_user | team_member | client",
      "ownerUserId": "uuid",
      "subjectId": "uuid",
      "subjectEmailNormalized": "string | null",
      "label": "string | null",
      "createdAt": "ISO8601",
      "rotatedAt": "ISO8601 | null",
      "lastAccessedAt": "ISO8601 | null"
    }
  ]
}
```

### POST /api/calendar/subscriptions

Create or rotate a calendar feed subscription.

**Body:**
```json
{
  "feedType": "go_user | team_member | client",
  "label": "string (optional)",
  "action": "create_or_rotate | ensure (optional, default: create_or_rotate)",
  "subjectId": "uuid (required for client feeds)"
}
```

**Response:**
```json
{
  "subscription": {
    "id": "uuid",
    "feedType": "go_user",
    "ownerUserId": "uuid",
    "subjectId": "uuid",
    "subjectEmailNormalized": "string | null",
    "label": "string | null",
    "rotated": false
  },
  "urls": {
    "webcal": "webcal://gigorganizer.com/api/calendar/feed/TOKEN",
    "https": "https://gigorganizer.com/api/calendar/feed/TOKEN",
    "google": "https://calendar.google.com/calendar/r?cid=..."
  }
}
```

---

## 10. Dashboard Analytics

### GET /api/dashboard/payments

Get payment analytics for a date range.

**Query params:**
- `start`: `YYYY-MM-DD` (required)
- `end`: `YYYY-MM-DD` (required)
- `page`: number (optional, default: 1)
- `limit`: number (optional, default: 25, max: 5000)
- `tz`: IANA timezone (optional, e.g., `America/Los_Angeles`)

**Response:**
```json
{
  "items": [
    {
      "id": "uuid",
      "contractId": "uuid",
      "eventDate": "YYYY-MM-DD",
      "eventTitle": "string",
      "clientName": "string",
      "contractFeeAmount": 250000,
      "depositAmount": 125000,
      "balanceAmount": 125000,
      "depositPaidAt": "ISO8601 | null",
      "balancePaidAt": "ISO8601 | null",
      "paymentStatus": "string"
    }
  ],
  "kpis": {
    "totalFees": 250000,
    "totalDepositsCollected": 125000,
    "totalBalanceCollected": 0,
    "totalOutstanding": 125000,
    "gigsWithDeposit": 1,
    "gigsWithBalance": 0,
    "gigsOverdue": 0
  },
  "total": 1,
  "page": 1,
  "limit": 25
}
```

All monetary values are in **cents**.

### GET /api/dashboard/team-invoices [read]

List team invoices issued by team members against the organizer's gigs.
Requires READ_SCOPE or FULL_SCOPE when called with an API token.

**Response:**
```json
{
  "invoices": [
    {
      "id": "uuid",
      "status": "draft | issued | paid | void",
      "issuedAt": "ISO8601",
      "paidAt": "ISO8601 | null",
      "voidedAt": "ISO8601 | null",
      "totalCents": 125000,
      "currency": "USD",
      "teamMemberName": "string | null",
      "contractId": "uuid",
      "contractTitle": "string | null",
      "contractEventDate": "YYYY-MM-DD | null",
      "venueName": "string | null",
      "venueCity": "string | null",
      "notes": "string | null",
      "preferredPaymentMethod": "string | null",
      "venmoHandle": "string | null",
      "paypalMe": "string | null",
      "zelleHandle": "string | null",
      "cashappHandle": "string | null",
      "isRedacted": false
    }
  ],
  "isPro": true
}
```

Free-tier organizers receive teaser rows only: void invoices are omitted, and
amounts, notes, line-item detail, and payment handles are redacted. Redacted
rows keep the shared teaser shape from the web dashboard and set
`isRedacted: true`.

---

## 11. User Profile

### GET /api/user/profile

Get the authenticated user's profile.

**Response:**
```json
{
  "userId": "uuid",
  "profile": {
    "firstName": "string | null",
    "lastName": "string | null",
    "businessName": "string | null",
    "fullName": "string | null",
    "phone": "string | null",
    "venmoHandle": "@username | null",
    "paypalMe": "username | null",
    "zelleEmail": "string | null",
    "mailingAddress": "string | null",
    "websites": ["https://..."],
    "emailSignatureEnabled": true,
    "emailSignatureCustom": "string | null",
    "defaultInvoiceLineItems": [],
    "includeDemandInFeed": false,
    "demandFeedMinLevel": "elevated",
    "demandCalendarPreferences": {},
    "dashboardMode": "light | dark | system"
  },
  "email": "user@example.com"
}
```

### PATCH /api/user/profile

Update profile fields. All fields optional, camelCase.

**Body:**
```json
{
  "firstName": "string",
  "lastName": "string",
  "businessName": "string",
  "fullName": "string",
  "phone": "string",
  "venmoHandle": "@username",
  "paypalMe": "username",
  "zelleEmail": "email",
  "mailingAddress": "string",
  "websites": ["https://..."],
  "emailSignatureEnabled": true,
  "emailSignatureCustom": "string"
}
```

**Response:**
```json
{
  "success": true,
  "profile": { "...updated fields..." }
}
```

### GET /api/settings/profile [read]

Read a performer's public-profile record (slug, bio, photos, videoLinks,
`profilePublic`, attached `leadFormId`). Pass `?performerNameId={uuid}`. Read an
agent's current state before PATCHing. (MCP: `manage_public_profile` action=get.)

### GET /api/settings/contract-defaults [read]

Read the organizer's contract-default cascade layers (fee, ensemble size, legal
posture, cancellation policy, deposit, event types) across all scopes.
(MCP: `manage_contract_defaults` action=get.)

### PATCH /api/settings/contract-defaults [full]

Upsert ONE contract-default cascade layer. **Body:** `{ scope, scopeKey?, settings }`
— `scope` is one of `global` / `event_type` / `performer_name` / `venue` / `client`;
`scopeKey` is required for non-global scopes and its shape depends on the scope:
a **title-case** canonical event-type string for `event_type` (e.g. `"Wedding"`),
or the row UUID for `performer_name` / `venue` / `client`. `settings` holds the
fields to set — `feeAmount`, `numberOfMusicians`, `legalPosture`, `depositMode`,
`cancellationPreset`, etc. — validated against canonical enums; enhanced presets
are Pro-gated. **`settings` REPLACES the whole layer** — send the COMPLETE settings
object, not just changed fields (a partial object deletes the rest). To edit one
field on an existing layer, `GET` it first, merge your change, and PATCH the full
object. Requires FULL_SCOPE when called with an API token. (MCP: `manage_contract_defaults` action=update.)

### GET /api/settings/team-defaults [read]

Read the organizer's team-default cascade layers (default team-member pay, arrival
offset, notes template, headcount alerts, team-portal section visibility, auto-send)
across all scopes, plus scope-key lookups (event types, performers, clients, venues).
(MCP: `manage_team_defaults` action=get.)

### PATCH /api/settings/team-defaults [full]

Upsert or clear ONE team-default cascade layer. **Body (set):**
`{ op: "cascade-set", scope, scopeKey?|clientId?, settings }`; **(clear):**
`{ op: "cascade-clear", scope, scopeKey?|clientId?, keys }`. `scope` is one of
`global` / `event_type` / `performer_name` / `venue` / `client`; for non-global
scopes pass `scopeKey` (title-case event-type for `event_type`, row UUID for
`performer_name` / `venue`) or `clientId` (the `user_clients` UUID for `client`).
`settings` keys: `default_pay_cents`, `arrival_offset_minutes`, `notes_template`,
`headcount_alerts_enabled`, `headcount_alert_days_before`, `headcount_notify_email`,
`auto_send_hold_emails`, `auto_send_confirmed_emails`, `sections`, `appearance`.
**cascade-set REPLACES the whole layer** — send the COMPLETE settings object.
Configuring team defaults is **free SETUP** for any signed-in user; only
`team_invoicing_enabled` is Pro-gated (the route returns a tier error for non-Pro).
Requires FULL_SCOPE when called with an API token. (MCP: `manage_team_defaults`
action=set / action=clear.)

### GET /api/user/performer-names [read]

List the organizer's performer identities (id, name, isDefault, isSelf, per-identity
defaults, profileSlug/profilePublic). Use to pick a `performerNameId` for profile or
contract-default writes. (MCP: `manage_performer_name` action=list.)

### POST /api/user/performer-names [full]

Create a new performer identity on the canonical identity route. Requires FULL_SCOPE
when called with an API token. Creating an identity is **free SETUP** for any
authenticated caller (standing up your own presence is acquisition, not live-gig
management). The Pro-facing per-identity **MONEY defaults** (`defaultFee`,
`defaultTeamMemberPayCents`) are **stripped for non-Pro callers** — a field-level
strip, NOT a whole-request 403 (mirrors POST /api/leads/forms and the PATCH edit
route). (MCP: `manage_performer_name` action=create.)

**Body:** `name` is required. Optional: `defaultFee` (string, **Pro-only**),
`defaultMusicianCount` (numeric string, 1–100, free), `defaultTeamMemberPayCents`
(integer, **Pro-only**), `isDefault` (boolean, free — when true, atomically promotes
this identity to the user's single default after insert). A duplicate name (same
`(user_id, name_normalized)`) returns **409**.

**Response (201):**
```json
{
  "performerName": {
    "id": "uuid",
    "name": "Trio Paz",
    "isDefault": true,
    "defaultFee": "string | null",
    "defaultMusicianCount": "string | null",
    "defaultTeamMemberPayCents": "number | null"
  },
  "warning": "string (optional — present only if isDefault promotion failed post-insert)"
}
```

The identity insert is the primary operation. If `isDefault:true` was requested but
the secondary default-promotion step fails, the route still returns **201** with
`isDefault:false` and a top-level `warning` string (the identity exists — retry just
the default promotion via the PATCH route) rather than a misleading 500.

### PATCH /api/user/autofill/performer/{id} [full]

Update an existing performer identity. Requires FULL_SCOPE when called with an API
token. Editing identity basics is **free SETUP**; the Pro-facing **MONEY defaults**
(`defaultFee`, `defaultTeamMemberPayCents`) are **stripped for non-Pro callers** — a
field-level strip, NOT a 403 (a non-Pro caller's money fields are silently ignored;
if those are the only fields sent, the edit is a no-op success). (MCP:
`manage_performer_name` action=update.) A **rename** cascades to FUTURE contracts
(atomic `cascade_performer_rename` RPC); `isDefault` and `isSelf` each swap atomically
(one default + one self identity per user). Renaming to an existing name returns **409**.

**Body:** all optional. The full route body (token callers reach all of these):

- `name` (string, 1–200, free) — rename; cascades to FUTURE contracts.
- `defaultFee` (string, ≤50, or `null` to clear) — **Pro-only**.
- `defaultMusicianCount` (numeric string `^\d{1,3}$`, validated 1–100, or `null`) — free.
- `defaultTeamMemberPayCents` (integer 0–100000000, or `null`) — **Pro-only**.
- `isDefault` (boolean) — atomic swap (one default identity per user).
- `isSelf` (boolean) — atomic swap (one self identity per user; independent of `isDefault`).
- `organizerFillsSlot` (boolean) — whether the organizer fills a roster slot for this identity.
- `requirementsForPlanner` (string, ≤2000, trimmed; `null`/blank clears) — planner-facing requirements note.
- `teamLogisticsDaysBefore` (integer 1–30, or `null` to inherit the cascade) — days before the gig to send team-logistics emails.

The `manage_performer_name` MCP tool intentionally surfaces only the identity-basics
subset (`name`, `defaultFee`, `defaultMusicianCount`, `defaultTeamMemberPayCents`,
`isDefault`, `isSelf`); the remaining fields are reachable via a direct PAT call to
this route.

**Response:**
```json
{ "success": true, "contractsUpdated": 0 }
```

### PATCH /api/settings/profile [full]

Update a performer public-profile record. Requires FULL_SCOPE when called with
an API token. (MCP: `manage_public_profile` action=update.) Note: photos are set
by **https URL** in the `photos` array here — agents do not use the multipart
`photos/upload` route below.

**Body:** `performerNameId` is required. Optional fields include `slug`,
`bio`, `photos`, `videoLinks`, `profilePublic`, and `leadFormId`.

**Response:**
```json
{
  "ok": true,
  "slug": "performer-slug",
  "bio": "string | null",
  "photos": ["https://..."],
  "videoLinks": ["https://..."],
  "profilePublic": true,
  "leadFormId": "uuid | null"
}
```

### POST /api/settings/profile/photos/upload [full]

Upload a performer public-profile photo. Requires FULL_SCOPE when called with
an API token.

**Body:** multipart form data with `photo` (`File`) and `performerNameId`.

**Response:**
```json
{
  "photoUrl": "https://...",
  "storagePath": "user_id/performer_name_id/photo.jpg"
}
```

### DELETE /api/settings/profile/photos/upload [full]

Delete a performer public-profile photo from storage. Requires FULL_SCOPE when
called with an API token.

**Query:** `photoUrl=https://...`

**Response:**
```json
{ "ok": true }
```

## Venues

### GET /api/user/venues [read]

List saved venues for autofill and migration matching.

**Response:**
```json
{
  "venues": [
    {
      "id": "uuid",
      "name": "string",
      "address": "string | null",
      "defaultDirections": "string | null",
      "venueContacts": [],
      "venueBackline": []
    }
  ]
}
```

### POST /api/user/venues [full]

Create a saved venue. Full-scope tokens can use this before creating or staging
gigs so venue details are available for autofill.

**Body:**
```json
{
  "name": "string (required)",
  "address": "string",
  "defaultDirections": "string",
  "venueContacts": [{ "name": "string", "phone": "string", "role": "string" }]
}
```

### PATCH /api/user/venues [full]

Update a saved venue. `id` is required; all other fields are optional.
`DELETE /api/user/venues` remains session-only.

**Body:**
```json
{
  "id": "uuid",
  "name": "string",
  "address": "string",
  "defaultDirections": "string",
  "venueContacts": []
}
```

---

## 12. Saved Roster (People Directory)

### GET /api/user/musicians

Get saved team members for autofill when adding to gigs.

**Response (camelCase):**
```json
{
  "musicians": [
    {
      "id": "uuid",
      "name": "Mike Johnson",
      "email": "mike@example.com",
      "phone": "555-0100 | null",
      "defaultPay": 25000,
      "notes": "string | null",
      "useCount": 5,
      "lastUsedAt": "ISO8601 | null",
      "createdAt": "ISO8601 | null"
    }
  ]
}
```

### POST /api/user/musicians

Add a team member to the saved roster.

**Body:**
```json
{
  "name": "string (required, 1-100 chars)",
  "email": "string (optional)",
  "phone": "string (optional, max 20)",
  "defaultPay": 25000,
  "notes": "string (optional, max 500)"
}
```

### PATCH /api/user/musicians/{id} [full]

Update a saved roster member. Full-scope tokens can use this for safe contact hygiene before adding a person to a gig.

Source route: `PATCH /api/user/musicians/[id]`.

**Body:**
```json
{
  "name": "string (optional, 1-100 chars)",
  "email": "string | null",
  "phone": "string | null",
  "teamMemberType": "string | null",
  "defaultPay": 25000,
  "notes": "string | null"
}
```

Duplicate emails and duplicate normalized phone numbers are rejected. `DELETE /api/user/musicians/{id}` remains dashboard/session-only.

**Response:**
```json
{
  "musician": {
    "id": "uuid",
    "name": "string",
    "email": "string | null",
    "phone": "string | null",
    "teamMemberType": "string | null",
    "defaultPay": 25000,
    "notes": "string | null",
    "useCount": 5,
    "lastUsedAt": "ISO8601 | null",
    "createdAt": "ISO8601 | null"
  }
}
```

**Errors (stable):**
- `400` — invalid body, no fields to update, invalid phone, duplicate email, or duplicate phone
- `403` — read-scope token attempted this full-scope action
- `404` — saved roster member not found for this user

---

## 13. Contacts

### GET /api/contacts

Search and list contacts.

**Token scope:** `[read]`

**Query params:**
- `search`: string (searches name, email, phone, organization)
- `role`: string (filter by role, or `"unassigned"`)
- `limit`: number (default: 50, max: 100)
- `offset`: number (default: 0)

**Response:**
```json
{
  "contacts": [
    {
      "id": "uuid",
      "name": "string | null",
      "email_raw": "string | null",
      "phone_raw": "string | null",
      "organization": "string | null",
      "title": "string | null",
      "notes": "string | null",
      "roles": ["client", "team_member"],
      "created_at": "ISO8601",
      "last_used_at": "ISO8601 | null"
    }
  ],
  "total": 42,
  "limit": 50,
  "offset": 0
}
```

### POST /api/contacts

Create or upsert a contact for the authenticated user.

**Token scope:** `[full]`

Personal access token callers can create contacts without a browser session. Contact writes use the service-role admin client with explicit user ownership filters so token callers do not depend on session-bound `auth.uid()` RPC behavior.

**Body fields:**
- `name`: string
- `email`: string | null
- `phone`: string | null
- `notes`: string | null
- `roles`: array of `team_member | client | planner | point_person | payment_contact`
- `default_pay_cents`: number | null (only used when `roles` includes `team_member`)
- `address_line1`: string | null
- `address_line2`: string | null
- `city`: string | null
- `state`: string | null
- `zip`: string | null

At least one of `name`, `email`, or `phone` is required. When `roles` includes `team_member`, the API also creates or links the saved roster entry and enforces the user's team-member tier limit. Omitted fields preserve existing values on upserts (matching contact found by email or phone) — in particular `default_pay_cents` is preserved when not supplied.

**Response:**
```json
{
  "contact": {
    "id": "uuid",
    "name": "string | null",
    "email_raw": "string | null",
    "phone_raw": "string | null",
    "organization": "string | null",
    "title": "string | null",
    "notes": "string | null",
    "roles": ["client"],
    "created_at": "ISO8601",
    "last_used_at": "ISO8601 | null"
  }
}
```

**Errors (stable):**
- `400` — invalid body, no identifying fields, invalid email, invalid phone, invalid role, or team-member limit exceeded for the user's plan
- `403` — read-scope token attempted this full-scope action, or subscription gate failed
- `409` — contact could not be created because the identifying details conflict with another saved contact

### GET /api/contacts/{id}

Read one contact owned by the authenticated user.

**Token scope:** `[read]`

**Response:**
```json
{
  "contact": {
    "id": "uuid",
    "name": "string | null",
    "email_raw": "string | null",
    "phone_raw": "string | null",
    "organization": "string | null",
    "title": "string | null",
    "notes": "string | null",
    "roles": ["client", "planner"],
    "review_requests_suppressed": false,
    "created_at": "ISO8601",
    "last_used_at": "ISO8601 | null"
  }
}
```

**Errors (stable):**
- `403` — token lacks read access
- `404` — contact not found for this user

### PATCH /api/contacts/{id}

Update one contact owned by the authenticated user.

**Token scope:** `[full]`

**Body fields:**
- `name`: string
- `email`: string | null
- `phone`: string | null
- `organization`: string | null
- `title`: string | null
- `notes`: string | null
- `address_line1`: string | null
- `address_line2`: string | null
- `city`: string | null
- `state`: string | null
- `zip`: string | null
- `country`: string | null
- `addRole`: `team_member | client | planner | point_person | payment_contact`
- `removeRole`: `team_member | client | planner | point_person | payment_contact`
- `reviewRequestsSuppressed`: boolean

Role mutations are applied through `addRole` and `removeRole`. Sending a `roles` array is ignored. Both `addRole` and `removeRole` may appear in the same request; the addition is applied first, then the removal, both inside a single atomic UPDATE so concurrent PATCH callers cannot race. When `addRole` is `team_member`, the API also creates or updates the saved roster entry and enforces the user's team-member tier limit (returns `400` if reached). When `removeRole` is `team_member`, the corresponding `tax_team_members.contact_id` is nulled to preserve history.

`reviewRequestsSuppressed` is available only on plans that include the client review suppression feature.

**Response:** same contact shape as `GET /api/contacts/{id}`.

**Errors (stable):**
- `400` — invalid body, no fields to update, invalid email, invalid phone, invalid role, duplicate contact details, or team-member limit exceeded for the user's plan
- `403` — read-scope token attempted this full-scope action, or subscription gate failed
- `404` — contact not found for this user

### DELETE /api/contacts/{id} [session-only — dashboard]

Delete one contact owned by the authenticated user.

**Token scope:** session-only. Personal access tokens cannot delete contacts.

---

## 14. Tax (W-9 & 1099)

### GET /api/tax/team-members

List team members with payment totals and tax status.

**Query params:**
- `year`: number (default: current year)
- `filter`: `all | need_w9 | over_600 | ready_to_file | needs_attention`
- `roster`: `true` (simplified response for autofill)

**Response:**
```json
{
  "team_members": [
    {
      "id": "uuid",
      "name": "string",
      "email": "string | null",
      "phone": "string | null",
      "w9_status": "none | pending | completed",
      "tin_last4": "string | null",
      "total_cents": 85000,
      "needs_1099": true,
      "filing_status": "string | null",
      "W9Status": "string | null",
      "TINMatching": "string | null",
      "FederalStatus": "string | null"
    }
  ],
  "summary": {
    "total_count": 5,
    "need_w9_count": 2,
    "over_600_count": 3,
    "ready_to_file_count": 1,
    "total_paid_cents": 425000,
    "needs_attention_count": 1
  },
  "year": 2025
}
```

### POST /api/tax/team-members/{id}/w9 [session-only — dashboard]

Request W-9 from a team member (sent via TaxBandits). **Not available via API
token** — this route is session-authenticated and not in the token capability
allowlist. Direct the user to `https://gigorganizer.com/dashboard/tax/team` to
request W-9s. You can still read W-9 status via `GET /api/tax/team-members`.

**Body:** None required.

**Response:**
```json
{
  "message": "W-9 request sent successfully",
  "PayeeRef": "string",
  "W9Url": "string"
}
```

Idempotent retry returns `{ "message": "...", "PayeeRef": "...", "idempotent": true }` (no `W9Url`).

### POST /api/tax/team-members/{id}/w9/resend [session-only — dashboard]

Resend a W-9 request email. **Not available via API token** (session-only, like
the W-9 request above) — direct the user to `https://gigorganizer.com/dashboard/tax/team`.

**Body:** None required.

**Response:**
```json
{
  "message": "W-9 request email resent successfully",
  "teamMemberId": "uuid",
  "PayeeRef": "string",
  "W9Url": "string",
  "email": "string"
}
```

### POST /api/tax/filings/create [session-only — dashboard]

> These routes use session-only auth and are not token-accessible. Direct users to: `https://gigorganizer.com/dashboard/tax`

Create a 1099 filing draft.

**Body:**
```json
{
  "team_member_id": "uuid (required)",
  "tax_year": 2025,
  "manual_tin": "123456789 (optional, 9 digits)",
  "tin_type": "SSN | EIN (optional)",
  "box1_name": "string (optional, for EIN filings)",
  "box2_business_name": "string (optional)"
}
```

**Response:**
```json
{
  "filing_id": "uuid",
  "status": "created",
  "submission_id": "string",
  "record_id": "string",
  "message": "1099 draft created. Use 'Transmit to IRS' to send (requires credit)."
}
```

### POST /api/tax/filings/file [session-only — dashboard]

Submit a 1099 filing to the IRS via TaxBandits.

**Body:**
```json
{
  "team_member_id": "uuid (required)",
  "tax_year": 2025,
  "skip_tin_match": false,
  "address_source": "on_file | filing_partner (optional)",
  "address_update_policy": "always_use | keep_both (optional)"
}
```

**Response:**
```json
{
  "filing_id": "uuid",
  "status": "transmitted | accepted | tin_matching | tin_mismatch | pending_payee_review | verified | created | failed",
  "submission_id": "string (may be absent for some statuses)",
  "record_id": "string (may be absent for some statuses)",
  "tin_match_submission_id": "string (if TIN match triggered)",
  "message": "string"
}
```

**Filing threshold:** 1099-NEC required for payments >= $600/year per payee.
**IRS deadline:** January 31 for the previous tax year.

---

## 15. API Token Management

### POST /api/settings/api-tokens [session-only — dashboard]

Generate a new API token. Requires Pro subscription. Session auth only (not API token) — a `go_pro_*` bearer call is rejected; create tokens at gigorganizer.com/dashboard/settings/api.

**Body:**
```json
{
  "name": "Claude Code on MacBook",
  "scope": "full",
  "expires_in_days": 90
}
```

**Response (201):**
```json
{
  "id": "uuid",
  "token": "go_pro_abc123... (shown once, copy immediately)",
  "prefix": "go_pro_abc123",
  "name": "Claude Code on MacBook",
  "scope": "full",
  "created_at": "ISO8601",
  "expires_at": "ISO8601 | null"
}
```

**Limits:** Maximum 10 active tokens. Returns 409 if cap reached.

### GET /api/settings/api-tokens [session-only — dashboard]

List all API tokens (never includes `token_hash`). Session auth only — not callable with a `go_pro_*` bearer token.

**Response:**
```json
{
  "tokens": [
    {
      "id": "uuid",
      "name": "string",
      "token_prefix": "go_pro_abc123",
      "scope": "full",
      "last_used_at": "ISO8601 | null",
      "created_at": "ISO8601",
      "revoked_at": null,
      "expires_at": "ISO8601 | null"
    }
  ]
}
```

### POST /api/settings/api-tokens/{id}/revoke [session-only — dashboard]

Revoke an API token. Session auth only — not callable with a `go_pro_*` bearer token.

**Response:**
```json
{
  "message": "Token revoked",
  "id": "uuid"
}
```

---

## Error Responses

All routes return errors in this format:
```json
{
  "error": "Human-readable error message"
}
```

Common status codes:
| Code | Meaning |
|------|---------|
| 400 | Invalid request body or missing fields |
| 401 | Not authenticated |
| 402 | Correction fee required (e-sign resend) |
| 403 | Not Pro / insufficient permissions |
| 404 | Resource not found |
| 409 | Conflict (duplicate, cap reached, already processed) |
| 429 | Rate limited (cooldown or token rate limit) |
| 500 | Internal error |
| 502 | Upstream provider error (TaxBandits) |
