---
title: "Create a new price alert (tier-quota + daily-cap gated)"
method: POST
path: "/api/alerts"
tags: ["Alerts"]
---

# Create a new price alert (tier-quota + daily-cap gated)

`POST /api/alerts`

## Request

- Base URL: `https://www.oxfordledge.com` (the document declares no server; this is the origin it was published from)
- URL: `https://www.oxfordledge.com/api/alerts`
- Auth: none declared

Create a new alert. Two gates fire before the INSERT:

1. Tier-quota gate (§3): if the user has hit `ALERT_TIER_QUOTAS[
   tier]` active alerts, raise `TierGateException` with
   `requiredTier=` next-tier-up. The custom 402 handler in
   `routes/api_errors.py` produces the structured payload the
   frontend `tier-gate.js` already understands.
2. Daily-create cap (§8 audit-fix #4): if the user has already
   created `DAILY_CREATE_CAP_PER_USER` (=50) alerts in the last
   24h, raise 429. Defeats the POST/DELETE churn vector that
   would otherwise let a free-tier user operate an effectively-
   unlimited rolling pool.

Body shape (JSON):
  {
    "ticker": "AAPL",                     # required
    "condition_type": "price_above",      # required
    "threshold": 250.00,                  # required for price/pct/volume types
    "note": "buy zone for value",         # optional
    "expires_at": "2026-06-30T00:00:00Z"  # optional
  }

PR1 ships only `price_above` and `price_below`. Other condition
types are reserved for PR2; the route accepts them through the
schema but the cron won't evaluate them yet.

## Response `200`

Successful Response

- AlertCreateResponse: `POST /api/alerts` response. Frontend reads `data.alert` to immediately add the new row to the list-view without re-fetching + `data.quota_remaining` to update the quota banner.
  - `alert` Alert, required: A single price_alerts row (or its in-memory equivalent). Fields are required when every code path in the route handlers populates them. `triggered_at`, `expires_at`, `last_checked_at`, `note` are nullable in the schema and Optional here. `threshold` is Optional because calendar-driven types (earnings_day / ex_dividend) carry no numeric threshold (PR2 feature, but the schema must already accommodate it so the PR1 row shape is forward-compatible).
    - `id` integer, required: Primary key.
    - `user_id` integer, required: Owning user's id.
    - `ticker` string, required: Stock ticker (uppercased + stripped).
    - `condition_type` string, required: One of 'price_above', 'price_below', 'pct_move', 'volume_spike', 'earnings_day', 'ex_dividend'. PR1 ships only the price_above/price_below pair; the others are reserved for PR1+ schema-forward-compat.
    - `threshold` number, nullable: Numeric threshold (price level for price_above/below; % for pct_move; multiple of avg-vol for volume_spike). Null for calendar-driven types.
    - `note` string, nullable: User's optional 'why this price' note — PROFESSOR's educational hook surfaced in the PR2 trigger email body.
    - `status` string, required: One of 'active', 'triggered', 'cancelled', 'expired', 'paused_by_downgrade' (Q4 OWNER decision 2026-04-30).
    - `created_at` string, required: ISO-8601 timestamp.
    - `triggered_at` string, nullable: ISO-8601 timestamp; null until the cron fires the alert.
    - `expires_at` string, nullable: ISO-8601 timestamp; optional auto-expire.
    - `last_checked_at` string, nullable: ISO-8601 timestamp of last cron tick that evaluated this alert.
  - `quota_remaining` integer, required: Decremented quota counter post-create. Equal to quota_total - count_of_active_alerts (post-INSERT).

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `402`: Payment Required
- `429`: Too Many Requests
- `500`: Internal Server Error

---

[API](https://skmtc.dev/oxfordledge/apis/oxford-ledge-api.md) · [All operations](https://skmtc.dev/oxfordledge/apis/oxford-ledge-api/llms.txt) · [OpenAPI document](https://skmtc.dev/oxfordledge/apis/oxford-ledge-api/revisions/e832a1260f0b?raw)
