◆ REFERENCE

API reference

Swing Deck exposes two HTTP surfaces. The cloud API at api.swing-deck.com handles license validation, checkout, sync, and alerts. The local dashboard at http://localhost:8001 serves the audit data, runs the AI coaches, and brokers order placement against E*TRADE / Tradier. Most endpoints are called by the dashboard automatically — this reference is for custom integrations or if you're auditing the behavior.

⬢ Cloud API — api.swing-deck.com

Base URL

Production: https://api.swing-deck.com

Interactive OpenAPI: api.swing-deck.com/docs (FastAPI auto-generated)

Authentication

Three auth patterns exist. Use whichever the endpoint requires:

PatternHeader / BodyUsed by
License key X-License-Key: SWING-XXXX-XXXX-XXXX All user-data endpoints (alerts, sync, portal, referrals)
None Public health, version check, checkout creation, trial signup
Cron secret Authorization: Bearer <CRON_SECRET> Internal: /api/cron/* endpoints

Endpoints

System

GET/health
Liveness probe — returns status=ok and a timestamp. Used by uptime monitors and Railway's health check.
GET/api/metrics
Public status — health of each dependent subsystem (database, billing, email). Drives the status page. No auth, no sensitive data.
GET/api/latest-version
Returns the currently-shipping version, minimum supported version (for force-upgrade), and download URLs per OS.

License

POST/api/validate-license
Body: {key: "SWING-XXXX-XXXX-XXXX"} — or pass via X-License-Key header.
Returns tier, feature flags, email, expiry. Cached 24h by the local app.

Billing

POST/api/create-checkout-session
Body: {plan, email?, referral_code?, success_url?, cancel_url?}.
plan: one of pro_monthly, pro_yearly, premium_monthly, premium_yearly.
Returns a short-lived Stripe Checkout URL.
POST/api/portal/session
Requires X-License-Key. Returns a Stripe Billing Portal URL where the user can update their card, change plan, cancel, and download invoices.
POST/api/stripe-webhook
Internal — Stripe calls this on checkout.session.completed, customer.subscription.deleted, invoice.payment_failed, customer.subscription.updated. Verifies signature against STRIPE_WEBHOOK_SECRET.

Trial

POST/api/trial/start
Body: {email, source?}. No credit card required.
Issues a 14-day Pro-tier license. Emails the key. Refuses disposable-email domains and limits to 2 trials per IP per 24h.

Alerts

POST/api/alert-dispatch
Requires X-License-Key with alerts_email or alerts_push feature.
Body: {alert_type, ticker?, message, details?, severity}.
Dispatches to Resend (email) + ntfy.sh (push). Server-side 4h dedup on (email, alert_type, ticker).

Cloud sync

POST/api/sync/upload
Requires X-License-Key with cloud_sync feature.
Body: {blob, meta, passphrase_fp} where blob is base64(nonce || ciphertext || tag) encrypted client-side with AES-256-GCM.
Max size: 5 MB. Retention: newest 30 per license.
GET/api/sync/list
Returns snapshot metadata (no ciphertext) for the licensed user — up to 30 rows.
GET/api/sync/download/{snapshot_id}
Returns the encrypted blob. Ownership enforced — a license key cannot download another user's snapshot.
DELETE/api/sync/{snapshot_id}
Delete a snapshot permanently. Cannot be undone.

Referrals

GET/api/referrals/stats
Requires X-License-Key. Returns referral code, share URL, total referred, credits earned, recent events.

v5.0 endpoints

Public endpoints added in v5.0. The consensus + daily-briefing endpoints require a valid license; track-record reads are public.

GET /api/track-record
Public — no auth. Returns the latest owner-published snapshot of notable fires: aggregate stats (ARMED/EXIT/TIGHTEN counts over 30 days) + recent signals with primitive, strength, and narrative. See swing-deck.com/track-record.
POST /api/daily-briefing
Called by the local dashboard at 6:25 AM MST weekdays. Requires X-License-Key and alerts_email entitlement. Accepts portfolio summary + state counts + actionable tickers; sends formatted email via Resend. Rate-limited.
POST /api/consensus/submit
Local dashboards submit anonymized trigger fires here every 60 seconds. Payload: {fires: [{ticker,kind,strength,state}], regime}. License key is hashed server-side (SHA-256, first 16 hex chars) — the raw key is never stored. Dedupes per (hash, ticker, primitive, day).
GET /api/consensus/ticker/{ticker}
Aggregate view: {sample_users, armed_pct, tighten_pct, exit_pct, top_kinds}. Requires a license. Enforces a k-anonymity floor of 5 users — returns {"sufficient": false} below that so no single user's data leaks.
GET /api/consensus/top
Today's tickers ranked by ARMED-user count. Query param limit=N (1-50, default 10). Same k-anonymity floor — tickers with <5 users are omitted.
GET /api/latest-version
No auth. Returns {version, min_version, download: {mac, win}, release_notes}. Auto-updater polls this every 24h. Below min_version → dashboard shows a hard-upgrade banner.

v6.4 endpoints (NEW · Founding 100 launch)

Public endpoints added with the v6.4 launch. Both are unauthenticated reads — they drive the live counter on /pricing and the tier-aware welcome page after Stripe checkout.

GET /api/founding-counter
Public — no auth. Returns the live state of the Founding 100 program:
{
  "claimed":    12,
  "total":      100,
  "remaining":  88,
  "closed":     false
}
closed flips to true once claimed >= 100. The counter is computed live from the licenses table (filtered to tier='founding' + status='active') so it reflects cancellations.
GET /api/checkout-session-info?session_id={SESSION_ID}
Public — no auth. Drives the tier-aware /welcome.html page after a Stripe Checkout redirect (success_url=...?session_id={CHECKOUT_SESSION_ID}). Returns enough info to render the right hero block:
{
  "tier":            "founding",        // "founding" | "pro" | "premium" | "trial" | "unknown"
  "customer_email":  "you@example.com",
  "slot_number":     12,                 // founding only — null otherwise
  "slot_total":      100,                // founding only — null otherwise
  "downgraded_from_founding": false      // true iff the cap was hit during checkout and pro was issued instead
}
Returns 404 if the session_id doesn't exist in Stripe; 400 if the session is for an unknown price. The license key itself is never returned through this surface — it's delivered exclusively via the welcome email.

Rate limits

In-memory token bucket per (bucket, license_key_or_ip). Resets on deploy. Exceeding the limit returns 429 with a Retry-After header and a JSON body:

{
  "detail": {
    "error":       "rate_limit_exceeded",
    "bucket":      "alert_dispatch",
    "limit":       30,
    "window_sec":  3600,
    "retry_after": 120,
    "message":     "Rate limit: 30 per 1h"
  }
}
EndpointLimitWindowKeyed by
/api/validate-license1201 minutelicense key
/api/alert-dispatch301 hourlicense key
/api/sync/upload1024 hourslicense key
/api/create-checkout-session101 hourIP address
/api/portal/session101 hourlicense key
⬡ WHY THESE LIMITS The alert-dispatch limit is the tightest — 30/hr is well above what any sane configuration would trigger, but well below what a runaway client could spam Resend with. If your legitimate use case exceeds a bucket, email support and we'll look at it.

Error codes

StatusMeaningWhat to do
400Bad request — malformed body or invalid field valuesCheck the detail field for what's wrong
401Missing / invalid license key (on auth-protected routes)Re-check X-License-Key header
403Valid license but tier doesn't include this featureUpgrade, or check features dict from /validate-license
404Resource not found (e.g. snapshot id)Re-list; the id may have been deleted
429Rate limit exceededBack off per Retry-After header
502Upstream provider error (Stripe, Resend, Supabase)Usually transient — retry after 10s
503Feature not configured on this deploymentService is live but missing an env var (Stripe key, Admin secret, etc.)

Example — validating a key from bash

curl -X POST https://api.swing-deck.com/api/validate-license \
  -H "Content-Type: application/json" \
  -d '{"key": "SWING-XXXX-XXXX-XXXX"}'

# =>
# {
#   "valid": true,
#   "tier":  "pro",
#   "features": {"max_tickers": 25, "broker_writes": true, …},
#   "email":  "you@example.com",
#   "expires_at": null,
#   "days_remaining": null,
#   "message": "Active pro license"
# }

⬢ Local dashboard — http://localhost:8001

The local dashboard's HTTP server is the same one rendering the UI you click. It runs entirely on your machine — no traffic leaves localhost — and exposes endpoints for the audit data, the eight AI coaches, and the broker integration. Most of these are called automatically by the dashboard JS; this reference is for users who want to script against their own dashboard or build custom integrations.

⚠ CSRF REQUIRED ON POSTS

All POST endpoints validate an X-CSRF-Token header against an in-memory token regenerated on every server restart. Fetch the current token from GET /csrf-token first, then pass it on subsequent POSTs. Read endpoints (GET) are not CSRF-gated — they're heartbeats and audit data fetches.

CSRF token

GET/csrf-token
Returns the current per-server-launch CSRF token: {"token": "<64-hex-chars>"}. Pass this as X-CSRF-Token on every subsequent POST. The token rotates on server restart (and only on restart), so cache it for the duration of your script.

Audit data

GET/audit
Returns the full latest audit JSON — every ticker's score, grade, swing signal, sub-pillar scores, price-action triggers, options state, regime, and metadata. This is the same payload the dashboard renders from. Schema is large but stable; see audit_framework.py for field-level definitions.
POST/audit/run
Triggers a fresh audit run. Requires X-CSRF-Token. Returns immediately with {"started": true}; poll GET /audit for the new payload (typically ready within 30s for a 25-ticker portfolio). Subject to the dashboard's own rate-limit (1 run per 60s).

AI coaches (8 surfaces)

Each AI coach has the same endpoint shape: a GET that returns a cached narration if one exists, regenerating from the LLM only when the cache key bursts. Pass force=1 to bypass the cache. All eight coaches log to the same ai_thesis_log.jsonl on disk — see the history modal docs for the schema.

For the full input/output spec of each surface (cache keys, output shapes, hallucination guards), see the AI surfaces reference. The endpoints below are the call shape only.

GET/thesis/ai/generate?ticker=X&force=0|1
AI Thesis — gold. Plain-English thesis on the ticker, personalized to the user's preference pills (length, focus, tone). Always available. Spec →
GET/coach/advocate/generate?ticker=X&force=0|1
Devil's Advocate — purple. Auto-pairs with AI Thesis; argues the opposite of the framework's conclusion. Spec →
GET/coach/pillar/generate?ticker=X&force=0|1
Pillar Coach — red. Reveals when the framework state is EXIT/TIGHTEN/WATCH or pillars have fired. Spec →
GET/coach/exit/generate?ticker=X&force=0|1
Exit Coach — amber. Reveals when a TP rung hits or chandelier breaches. Spec →
GET/coach/entry/generate?ticker=X&force=0|1
Entry Coach — green. Reveals when an entry trigger arms (shares=0 + swing_signal=ARMED). Spec →
GET/coach/audit/generate?ticker=X&force=0|1
📊 Position Audit — cyan. Reads your trade journal + prior theses for this ticker; surfaces patterns, avg P/L %, hold time, fills-vs-thesis divergence. Spec →
GET/coach/catalyst/generate?ticker=X&force=0|1
📰 Catalyst Interpreter — blue. Classifies headlines as MATERIAL or NOISE; flags state-vs-news conflicts. Spec →
GET/coach/whale_confirmation/generate?ticker=X&force=0|1
🐋 Whale Confirmation Coach (NEW · v6.3) — cyan. AI cross-checks framework whale signals via web_search against SEC filings and financial press. Verdicts: CONFIRMED / CONFLICT / WEAK SIGNAL. Every cited URL is validated against the search result set. Spec →
GET/coach/trap_structure/generate?ticker=X&force=0|1
🪤 Trap & Structure Coach (NEW · v6.4) — purple. Classifies the current bar as TRAP / FADE / CHASE / CLEAN / NEUTRAL using ten S/R level sources. Issues an entry verdict: OK / WAIT / AVOID. Spec →

Broker

Order placement endpoints are intentionally not documented for direct curl use. They run through an 8-layer pre-flight (audit-freshness, confirm-token, position-size cap, risk-budget cap, options-level enforcement, halt check, dry-run window, idempotency keys), and bypassing the dashboard UI defeats those guards. Use the dashboard's order modals instead. The endpoints below are the broker-state surface, which is safe to script against.

GET/broker/circuit/status
Returns the circuit breaker state: {open: bool, cooldown_remaining: int, last_reason: str, fails: int}. The breaker trips after 3 consecutive 5xx responses from the upstream broker and locks for BROKER_CIRCUIT_COOLDOWN seconds (default 1800).
POST/broker/circuit/reset
Manually clears a tripped breaker. Requires X-CSRF-Token. Logs a circuit_reset event with source: manual_api. Useful when an upstream maintenance window cleared and you don't want to wait the full cooldown.
GET/broker/reconcile
Diffs what the dashboard has logged vs. what the broker currently shows: drift positions (different qty/avg-price), broker-only positions (in account but not in portfolio.txt), and local-only positions (in portfolio.txt but not at the broker). Read-only — surfaces the diff for human resolution.

Mode toggle

GET/api/broker-mode
Returns {mode: "live"|"dryrun"}. Dryrun short-circuits all place and cancel calls so they log the intent but never hit the broker; preview still runs against real prod for full validation.

Example — call a coach from bash

# 1. Fetch the CSRF token (only needed if you'll POST later;
#    /coach/* is GET so this step is skipped for read-only).
TOKEN=$(curl -s http://localhost:8001/csrf-token | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")

# 2. Generate the Trap & Structure narration for AAPL.
curl -s "http://localhost:8001/coach/trap_structure/generate?ticker=AAPL" | python3 -m json.tool

# =>
# {
#   "ticker":   "AAPL",
#   "state":    "FADE",
#   "verdict":  "WAIT",
#   "reason":   "Price tagged primary resistance at $269.77 with thin tape lean...",
#   "structure": { "primary_resistance": 269.77, "primary_support": 261.50, ... },
#   "exhaustion": { "rsi": 63, "momentum": "rolling_down" },
#   "tape":      { "lean_at_resistance": "thin" },
#   "better_entries": [{"price": 261.50, "reason": "support"}],
#   "cached":   false
# }