Reading additional input from stdin...
OpenAI Codex v0.128.0 (research preview)
--------
workdir: J:\CLAUDE\PROJECTS\Wakeword
model: gpt-5.5
provider: openai
approval: never
sandbox: danger-full-access
reasoning effort: xhigh
reasoning summaries: none
session id: 019e00d5-1cae-7ca0-b631-fee307372a18
--------
user
Environment: Windows 11, bash shell. Project root: J:\CLAUDE\PROJECTS\Wakeword (master).

PROBLEM
Stripe webhook idempotency in `console/backend/app/routes/billing.py` uses an in-memory `OrderedDict[str, None]` (1000-event cap). This survives only one process lifetime. If the backend restarts while Stripe is mid-redelivery (which Stripe does aggressively for failed events), the cache is empty and the same event ID can be processed twice → double tier-update, double email, double quota grant.

Move the idempotency cache from in-memory to **Postgres**. Schema:
- Table `processed_stripe_events` with columns `event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ DEFAULT NOW()`.
- On webhook receipt, INSERT `event_id` with `ON CONFLICT (event_id) DO NOTHING`. If `INSERT` affected 0 rows (i.e., event already processed), early-return 200 OK without re-running tier-update logic.
- Periodic cleanup: DELETE rows where `processed_at < NOW() - INTERVAL '30 days'` (run on every webhook receipt — opportunistic, low cost, keeps the table bounded).

INVESTIGATE FIRST
1. Read `console/backend/app/routes/billing.py` — find `_processed_events` and where it's gated. Note the function signature, the event-extraction flow, and whether it's called from FastAPI or a side path.
2. Read `console/backend/app/database.py` to see the Postgres connection pattern.
3. Read `console/backend/alembic/versions/` (latest) to see the migration template.
4. Read existing tests in `console/tests/test_billing.py` (Codex extended this earlier) to see how webhooks are tested.

DECIDE & IMPLEMENT

Backend code changes:
1. Create `console/backend/app/models.py` model `ProcessedStripeEvent` (or wherever SQLAlchemy models live in this codebase — check existing patterns).
2. Replace the `_processed_events` OrderedDict logic in `routes/billing.py` with a Postgres lookup/insert.
3. Add the cleanup query (opportunistic, run before the lookup).

Migration:
4. New Alembic migration in `console/backend/alembic/versions/` named like `<sha>_add_processed_stripe_events_table.py`. Use today's date prefix (`20260507_`).

Tests:
5. Update `console/tests/test_billing.py` (Codex's recently-added webhook idempotency test) to match the new contract — duplicate event still early-returns, but now backed by DB.
6. The FakeSession-based unit test should NOT need a real Postgres connection — make sure the test doesn't break. If it would require Postgres, mark it `@pytest.mark.integration` and add a non-integration version using mock.

CONSTRAINTS
- NEVER `git add -A`. Stage explicit files.
- NEVER push. Don't touch live deploy.
- The migration MUST be additive (CREATE TABLE) — never DROP, never ALTER NOT NULL on existing data.
- Do NOT use PowerShell with complex quoting.
- If `routes/billing.py` calls `_processed_events` from multiple sites, keep the helper function's interface but change its implementation.

PROVE IT
1. `cd console && python -m pytest tests/test_billing.py --no-cov --timeout=60 -q` — must still pass.
2. `cd console && python -m pytest tests/ --no-cov --timeout=60 --ignore=tests/e2e -q 2>&1 | tail -3` — overall count must be `>= previous (147 passed)`.
3. Show the migration file and confirm it has both `upgrade()` and `downgrade()`.

REPORT
- Files modified.
- Commit SHAs (logical: model + migration + route + tests).
- Final pytest summary.
- Any decisions about test mocking strategy.
- Heads-up to me: the next backend deploy needs to run `alembic upgrade head` (which `entrypoint.sh` does automatically) — confirm this is true.

Time budget: ~25 min.
codex
I’ll inspect the billing route, DB setup, latest migration style, and webhook tests first, then patch only the relevant files and run the requested test commands.
  → Inspect billing route, database setup, migrations, and tests
  • Implement Postgres-backed Stripe event idempotency and migration
  • Update webhook idempotency tests with mock-backed DB behavior
  • Run requested pytest commands and verify migration
  • Report files, SHAs/status, pytest summary, and deploy note
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/database.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'rg -n "_processed_events|webhook|stripe" console/backend/app/routes/billing.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'rg -n "webhook|idempot|billing|FakeSession|stripe" console/tests/test_billing.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-ChildItem console/backend/alembic/versions | Sort-Object Name | Select-Object -Last 5 Name' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 354ms:
1:"""Billing routes: Stripe checkout, webhook, subscription management, usage."""
42:    "developer": "stripe_price_developer",
43:    "business": "stripe_price_business",
52:_processed_events: OrderedDict[str, None] = OrderedDict()
68:def _get_stripe():
69:    """Return the stripe module, configured with the secret key.
71:    Deferred import so the app starts even when stripe is not installed
74:    import stripe
75:    stripe.api_key = settings.stripe_secret_key
76:    return stripe
146:async def _get_or_create_stripe_customer(
147:    stripe, db: AsyncSession, user: User, sub: Subscription,
150:    if sub.stripe_customer_id:
151:        return sub.stripe_customer_id
153:    customer = stripe.Customer.create(
158:    sub.stripe_customer_id = customer.id
294:    stripe = _get_stripe()
298:    customer_id = await _get_or_create_stripe_customer(stripe, db, current_user, sub)
323:    session = stripe.checkout.Session.create(
343:@router.post("/webhook")
344:async def stripe_webhook(
347:    stripe_signature: Annotated[str | None, Header(alias="stripe-signature")] = None,
349:    """Handle Stripe webhook events.
352:    via JWT. Instead, the webhook signature is verified using the shared
356:    stripe = _get_stripe()
358:    if not settings.stripe_webhook_secret:
366:    if not stripe_signature:
369:            detail="Missing stripe-signature header.",
373:        event = stripe.Webhook.construct_event(
375:            sig_header=stripe_signature,
376:            secret=settings.stripe_webhook_secret,
378:    except stripe.error.SignatureVerificationError:
382:            detail="Invalid webhook signature.",
387:    if event_id and event_id in _processed_events:
388:        logger.debug("Duplicate webhook event ignored: %s", event_id)
391:        _processed_events[event_id] = None
393:        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
394:            _processed_events.popitem(last=False)
398:    logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
409:        logger.debug("Ignoring unhandled webhook event type: %s", event_type)
427:    if sub.stripe_subscription_id and settings.billing_enabled:
429:            stripe = _get_stripe()
430:            stripe_sub = stripe.Subscription.retrieve(sub.stripe_subscription_id)
431:            if stripe_sub.status == "trialing" and stripe_sub.trial_end:
433:                trial_end = datetime.fromtimestamp(stripe_sub.trial_end, tz=timezone.utc)
437:                sub.stripe_subscription_id,
464:    stripe = _get_stripe()
467:    if not sub.stripe_customer_id:
473:    session = stripe.billing_portal.Session.create(
474:        customer=sub.stripe_customer_id,
505:    if price_id == settings.stripe_price_developer:
507:    if price_id == settings.stripe_price_business:
527:    sub.stripe_customer_id = customer_id
528:    sub.stripe_subscription_id = subscription_id
535:            stripe = _get_stripe()
536:            stripe_sub = stripe.Subscription.retrieve(subscription_id)
538:                stripe_sub.current_period_end, tz=timezone.utc,
545:        "Subscription activated: user=%d tier=%s stripe_sub=%s",
552:    stripe_sub_id = subscription.get("id")
554:        select(Subscription).where(Subscription.stripe_subscription_id == stripe_sub_id)
558:        logger.warning("subscription.updated for unknown stripe_subscription_id=%s", stripe_sub_id)
562:    stripe_status = subscription.get("status", "active")
573:    sub.status = status_map.get(stripe_status, "active")
601:    stripe_sub_id = subscription.get("id")
603:        select(Subscription).where(Subscription.stripe_subscription_id == stripe_sub_id)
607:        logger.warning("subscription.deleted for unknown stripe_subscription_id=%s", stripe_sub_id)
613:    sub.stripe_subscription_id = None
624:    stripe_sub_id = invoice.get("subscription")
625:    if not stripe_sub_id:
630:        select(Subscription).where(Subscription.stripe_subscription_id == stripe_sub_id)
634:        logger.warning("payment_failed for unknown stripe_subscription_id=%s", stripe_sub_id)
641:        sub.user_id, stripe_sub_id,

 succeeded in 366ms:
1:"""Billing and Stripe webhook tests for the ViolaWake Console backend."""
63:    email = f"billing_{time.time_ns()}@example.com"
124:    stripe_customer_id: str | None = "cus_test_123",
125:    stripe_subscription_id: str | None = "sub_test_123",
127:    """Insert a subscription row directly for webhook setup."""
137:                stripe_customer_id=stripe_customer_id,
138:                stripe_subscription_id=stripe_subscription_id,
160:            "stripe_customer_id": sub.stripe_customer_id,
161:            "stripe_subscription_id": sub.stripe_subscription_id,
184:def billing_settings(monkeypatch):
185:    """Enable billing and provide deterministic Stripe settings."""
188:    monkeypatch.setattr(settings, "stripe_secret_key", "sk_test_123")
189:    monkeypatch.setattr(settings, "stripe_webhook_secret", "whsec_test_123")
190:    monkeypatch.setattr(settings, "stripe_price_developer", "price_developer_test")
191:    monkeypatch.setattr(settings, "stripe_price_business", "price_business_test")
207:def make_stripe_mock() -> MagicMock:
208:    """Build a Stripe mock with the members billing routes use."""
213:    stripe = MagicMock()
214:    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
215:    return stripe
221:        response = client.get("/api/billing/subscription", headers=auth_user["headers"])
231:        response = client.get("/api/billing/usage", headers=auth_user["headers"])
254:        usage_response = client.get("/api/billing/usage", headers=auth_user["headers"])
286:    def test_checkout_returns_checkout_url(self, client, auth_user, billing_settings) -> None:
287:        stripe = make_stripe_mock()
288:        stripe.Customer.create.return_value = SimpleNamespace(id="cus_checkout_123")
289:        stripe.checkout.Session.create.return_value = SimpleNamespace(
291:            url="https://stripe.test/checkout/cs_checkout_123",
294:        with patch("app.routes.billing._get_stripe", return_value=stripe):
296:                "/api/billing/checkout",
302:        assert response.json()["checkout_url"] == "https://stripe.test/checkout/cs_checkout_123"
303:        stripe.checkout.Session.create.assert_called_once()
305:    def test_checkout_without_auth_requires_auth(self, client, billing_settings) -> None:
306:        response = client.post("/api/billing/checkout", json={"tier": "developer"})
314:    def test_webhook_checkout_session_completed_updates_subscription_tier(
318:        billing_settings,
339:        stripe = make_stripe_mock()
340:        stripe.Webhook.construct_event.return_value = event
341:        stripe.Subscription.retrieve.return_value = SimpleNamespace(current_period_end=period_end)
343:        with patch("app.routes.billing._get_stripe", return_value=stripe):
345:                "/api/billing/webhook",
347:                headers={"stripe-signature": "sig_test_123"},
354:            "/api/billing/subscription",
365:        assert row["stripe_customer_id"] == customer_id
366:        assert row["stripe_subscription_id"] == subscription_id
368:    def test_webhook_subscription_deleted_downgrades_to_free(
372:        billing_settings,
382:                stripe_customer_id=customer_id,
383:                stripe_subscription_id=subscription_id,
392:        stripe = make_stripe_mock()
393:        stripe.Webhook.construct_event.return_value = event
395:        with patch("app.routes.billing._get_stripe", return_value=stripe):
397:                "/api/billing/webhook",
399:                headers={"stripe-signature": "sig_test_123"},
405:            "/api/billing/subscription",
415:        assert row["stripe_subscription_id"] is None
418:    def test_invalid_webhook_signature_returns_400(
421:        billing_settings,
423:        stripe = make_stripe_mock()
424:        stripe.Webhook.construct_event.side_effect = stripe.error.SignatureVerificationError(
428:        with patch("app.routes.billing._get_stripe", return_value=stripe):
430:                "/api/billing/webhook",
432:                headers={"stripe-signature": "sig_invalid"},
436:        assert response.json()["detail"] == "Invalid webhook signature."
438:    def test_webhook_duplicate_event_id_is_processed_once(
440:        billing_settings,
442:        from app.routes import billing as billing_routes
445:            "id": "evt_idempotency_direct_test",
449:                    "id": "cs_idempotency_direct_test",
450:                    "customer": "cus_idempotency_direct_test",
451:                    "subscription": "sub_idempotency_direct_test",
456:        stripe = make_stripe_mock()
457:        stripe.Webhook.construct_event.return_value = event
462:        billing_routes._processed_events.clear()
465:                patch("app.routes.billing._get_stripe", return_value=stripe),
466:                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
469:                    billing_routes.stripe_webhook(
472:                        stripe_signature="sig_test_123",
476:                    billing_routes.stripe_webhook(
479:                        stripe_signature="sig_test_123",
483:            billing_routes._processed_events.clear()

 succeeded in 420ms:
"""SQLAlchemy async database setup."""

from __future__ import annotations

from collections.abc import AsyncGenerator

from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase

from app.config import settings

# Railway deployments should set VIOLAWAKE_DB_URL to a full
# postgresql+asyncpg://... URL so the backend uses persistent PostgreSQL
# instead of the default filesystem-backed SQLite database.
DATABASE_URL = (
    settings.db_url.strip()
    if settings.db_url and settings.db_url.strip()
    else f"sqlite+aiosqlite:///{settings.db_path}"
)

engine = create_async_engine(DATABASE_URL, echo=False)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)


class Base(DeclarativeBase):
    """Declarative base for all ORM models."""
    pass


def _ensure_schema_updates(connection: Connection) -> None:
    """Apply lightweight schema updates for local-first deployments."""
    inspector = inspect(connection)
    table_names = set(inspector.get_table_names())
    if "users" not in table_names:
        return

    user_columns = {column["name"] for column in inspector.get_columns("users")}
    if "email_verified" not in user_columns:
        connection.execute(
            text("ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE")
        )
    if "failed_login_count" not in user_columns:
        try:
            connection.execute(
                text(
                    "ALTER TABLE users ADD COLUMN IF NOT EXISTS "
                    "failed_login_count INTEGER DEFAULT 0 NOT NULL"
                )
            )
        except Exception:
            connection.execute(
                text("ALTER TABLE users ADD COLUMN failed_login_count INTEGER DEFAULT 0 NOT NULL")
            )
    if "locked_until" not in user_columns:
        try:
            connection.execute(
                text("ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP")
            )
        except Exception:
            connection.execute(text("ALTER TABLE users ADD COLUMN locked_until TIMESTAMP"))

    # Team FK columns on recordings and trained_models (nullable, so no default needed)
    if "recordings" in table_names:
        recording_columns = {col["name"] for col in inspector.get_columns("recordings")}
        if "team_id" not in recording_columns:
            connection.execute(text("ALTER TABLE recordings ADD COLUMN team_id INTEGER REFERENCES teams(id)"))

    if "trained_models" in table_names:
        model_columns = {col["name"] for col in inspector.get_columns("trained_models")}
        if "team_id" not in model_columns:
            connection.execute(text("ALTER TABLE trained_models ADD COLUMN team_id INTEGER REFERENCES teams(id)"))

    # Soft-delete support: recordings are marked deleted_at after training completes
    if "recordings" in table_names:
        recording_columns = {col["name"] for col in inspector.get_columns("recordings")}
        if "deleted_at" not in recording_columns:
            connection.execute(text("ALTER TABLE recordings ADD COLUMN deleted_at TIMESTAMP"))


async def get_db() -> AsyncGenerator[AsyncSession, None]:
    """FastAPI dependency that yields a database session."""
    async with async_session_factory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise


async def init_db() -> None:
    """Create all tables (idempotent)."""
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
        await conn.run_sync(_ensure_schema_updates)

 succeeded in 550ms:

Name                                                           
----                                                           
20260326_0001_a1b2c3d4e5f6_initial_schema.py                   
20260328_0001_b2c3d4e5f6a7_add_teams.py                        
20260405_0001_c3d4e5f6a7b8_add_login_lockout_and_soft_delete.py



codex
The route currently gates duplicates inside `stripe_webhook` after Stripe signature verification, before event-type dispatch. The DB setup uses a shared async SQLAlchemy `Base` and dependency-managed `AsyncSession`, so the cleanest change is a tiny model plus a helper that runs through the existing session.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/routes/billing.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-ChildItem console/backend/app' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/alembic/versions/20260405_0001_c3d4e5f6a7b8_add_login_lockout_and_soft_delete.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/tests/test_billing.py' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 487ms:
"""Add failed_login_count and locked_until to users, deleted_at to recordings.

These columns were previously applied at runtime by _ensure_schema_updates()
in database.py but were missing from the Alembic migration chain, causing
Alembic-only deployments (e.g. PostgreSQL on Railway) to lack them.

Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-04-05
"""
from __future__ import annotations

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "c3d4e5f6a7b8"
down_revision: Union[str, None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # Account-lockout columns on users
    op.add_column(
        "users",
        sa.Column("failed_login_count", sa.Integer, nullable=False, server_default=sa.text("0")),
    )
    op.add_column(
        "users",
        sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
    )

    # Soft-delete support on recordings
    op.add_column(
        "recordings",
        sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
    )


def downgrade() -> None:
    op.drop_column("recordings", "deleted_at")
    op.drop_column("users", "locked_until")
    op.drop_column("users", "failed_login_count")

 succeeded in 522ms:


    Directory: J:\CLAUDE\PROJECTS\Wakeword\console\backend\app


Mode                 LastWriteTime         Length Name                                                                 
----                 -------------         ------ ----                                                                 
d-----          5/6/2026   8:29 PM                routes                                                               
d-----          4/5/2026   4:50 AM                services                                                             
d-----          5/6/2026  11:55 PM                __pycache__                                                          
-a----          4/5/2026  11:09 AM          13642 auth.py                                                              
-a----          4/5/2026  11:08 AM           7295 config.py                                                            
-a----          4/5/2026   8:51 AM           3847 database.py                                                          
-a----          5/6/2026   8:28 PM           9291 email_service.py                                                     
-a----          4/5/2026   8:51 AM           6631 health.py                                                            
-a----          4/5/2026  10:44 AM          48298 job_queue.py                                                         
-a----          4/5/2026   9:22 AM           6994 main.py                                                              
-a----          5/6/2026  11:52 PM          13273 middleware.py                                                        
-a----          4/5/2026   8:51 AM           8065 models.py                                                            
-a----         3/26/2026  11:03 AM           6641 monitoring.py                                                        
-a----          5/6/2026  10:25 PM           3119 rate_limit.py                                                        
-a----         3/28/2026   8:48 PM           9122 retention.py                                                         
-a----          4/5/2026  11:09 AM           7096 schemas.py                                                           
-a----         3/26/2026  10:53 AM           9948 storage.py                                                           
-a----         3/25/2026  11:09 PM             34 __init__.py                                                          



 succeeded in 588ms:
"""Billing and Stripe webhook tests for the ViolaWake Console backend."""

from __future__ import annotations

import asyncio
import io
import sqlite3
import sys
import time
import wave
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from sqlalchemy import select

try:
    from fastapi.testclient import TestClient

    HAS_FASTAPI = True
except ImportError:
    HAS_FASTAPI = False

pytestmark = pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")

BACKEND_DIR = str(Path(__file__).resolve().parents[1] / "backend")
if BACKEND_DIR not in sys.path:
    sys.path.insert(0, BACKEND_DIR)


def make_wav_bytes(duration: float = 1.0, sr: int = 16000) -> bytes:
    """Generate a valid mono WAV file with a 440Hz sine tone.

    Previous implementation wrote all-zero PCM data which silently passed
    validation (before the energy check existed) and polluted training data.
    """
    import math
    import struct as _struct

    frame_count = int(duration * sr)
    samples = []
    for i in range(frame_count):
        # 440Hz sine at ~50% amplitude (16384 out of 32767)
        value = int(16384 * math.sin(2 * math.pi * 440 * i / sr))
        samples.append(_struct.pack("<h", value))
    pcm = b"".join(samples)

    buf = io.BytesIO()
    with wave.open(buf, "wb") as wav_file:
        wav_file.setnchannels(1)
        wav_file.setsampwidth(2)
        wav_file.setframerate(sr)
        wav_file.writeframes(pcm)

    buf.seek(0)
    return buf.read()


def register_user(client: TestClient) -> dict[str, object]:
    """Register a user and return auth headers plus user metadata."""
    email = f"billing_{time.time_ns()}@example.com"
    response = client.post(
        "/api/auth/register",
        json={"email": email, "password": "TestPass123!", "name": "Billing Test"},
    )
    assert response.status_code in (200, 201), response.text

    import sys

    if BACKEND_DIR not in sys.path:
        sys.path.insert(0, BACKEND_DIR)

    from app.config import settings

    with sqlite3.connect(settings.db_path) as conn:
        row = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
        assert row is not None, "Registered user not found"
        user_id = int(row[0])
        conn.execute("UPDATE users SET email_verified = 1 WHERE id = ?", (user_id,))
        conn.commit()

    login_response = client.post(
        "/api/auth/login",
        json={"email": email, "password": "TestPass123!"},
    )
    assert login_response.status_code == 200, login_response.text
    token = login_response.json()["token"]

    return {
        "email": email,
        "user_id": user_id,
        "headers": {"Authorization": f"Bearer {token}"},
    }


def upload_recordings(
    client: TestClient,
    auth_headers: dict[str, str],
    wake_word: str,
    count: int = 5,
) -> list[int]:
    """Upload enough recordings for a training request."""
    recording_ids: list[int] = []
    for index in range(count):
        response = client.post(
            "/api/recordings/upload",
            headers=auth_headers,
            files={"file": (f"sample_{index}.wav", make_wav_bytes(), "audio/wav")},
            data={"wake_word": wake_word},
        )
        assert response.status_code == 200, response.text
        payload = response.json()
        recording_ids.append(payload.get("id") or payload.get("recording_id"))
    return recording_ids


async def seed_subscription(
    *,
    user_id: int,
    tier: str = "developer",
    status: str = "active",
    stripe_customer_id: str | None = "cus_test_123",
    stripe_subscription_id: str | None = "sub_test_123",
) -> None:
    """Insert a subscription row directly for webhook setup."""
    from app.database import async_session_factory
    from app.models import Subscription

    async with async_session_factory() as session:
        session.add(
            Subscription(
                user_id=user_id,
                tier=tier,
                status=status,
                stripe_customer_id=stripe_customer_id,
                stripe_subscription_id=stripe_subscription_id,
                current_period_end=datetime.now(timezone.utc),
            )
        )
        await session.commit()


async def get_subscription_row(user_id: int) -> dict[str, object] | None:
    """Fetch the subscription row for assertions that are not exposed via the API."""
    from app.database import async_session_factory
    from app.models import Subscription

    async with async_session_factory() as session:
        result = await session.execute(
            select(Subscription).where(Subscription.user_id == user_id)
        )
        sub = result.scalar_one_or_none()
        if sub is None:
            return None
        return {
            "tier": sub.tier,
            "status": sub.status,
            "stripe_customer_id": sub.stripe_customer_id,
            "stripe_subscription_id": sub.stripe_subscription_id,
            "current_period_end": sub.current_period_end,
        }


@pytest.fixture(scope="module")
def client():
    """Create a FastAPI test client."""
    try:
        from app.main import app
    except ImportError as exc:
        pytest.skip(f"Backend not yet built: {exc}")

    return TestClient(app)


@pytest.fixture
def auth_user(client) -> dict[str, object]:
    """Create an authenticated user for a test."""
    return register_user(client)


@pytest.fixture
def billing_settings(monkeypatch):
    """Enable billing and provide deterministic Stripe settings."""
    from app.config import settings

    monkeypatch.setattr(settings, "stripe_secret_key", "sk_test_123")
    monkeypatch.setattr(settings, "stripe_webhook_secret", "whsec_test_123")
    monkeypatch.setattr(settings, "stripe_price_developer", "price_developer_test")
    monkeypatch.setattr(settings, "stripe_price_business", "price_business_test")
    monkeypatch.setattr(settings, "console_base_url", "http://localhost:5173")
    return settings


@pytest.fixture
def mock_training_queue():
    """Patch the queue dependency so training requests only exercise quota logic."""
    queue = SimpleNamespace()
    job_ids = iter(range(1, 1000))
    queue.submit_job = AsyncMock(side_effect=lambda **_: next(job_ids))

    with patch("app.routes.jobs.init_job_queue", new=AsyncMock(return_value=queue)):
        yield queue


def make_stripe_mock() -> MagicMock:
    """Build a Stripe mock with the members billing routes use."""

    class FakeSignatureVerificationError(Exception):
        pass

    stripe = MagicMock()
    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
    return stripe


class TestBillingRoutes:

    def test_get_subscription_returns_free_tier_for_new_user(self, client, auth_user) -> None:
        response = client.get("/api/billing/subscription", headers=auth_user["headers"])

        assert response.status_code == 200, response.text
        data = response.json()
        assert data["tier"] == "free"
        assert data["status"] == "active"
        assert data["usage"]["models_used"] == 0
        assert data["usage"]["models_limit"] == 3

    def test_get_usage_returns_zero_for_new_user(self, client, auth_user) -> None:
        response = client.get("/api/billing/usage", headers=auth_user["headers"])

        assert response.status_code == 200, response.text
        data = response.json()
        assert data["models_used"] == 0
        assert data["models_limit"] == 3

    def test_free_tier_user_can_start_three_training_jobs(
        self,
        client,
        auth_user,
        mock_training_queue,
    ) -> None:
        recording_ids = upload_recordings(client, auth_user["headers"], "quota-free-ok")

        for _ in range(3):
            response = client.post(
                "/api/training/start",
                headers=auth_user["headers"],
                json={"wake_word": "quota-free-ok", "recording_ids": recording_ids, "epochs": 5},
            )
            assert response.status_code == 202, response.text

        usage_response = client.get("/api/billing/usage", headers=auth_user["headers"])
        assert usage_response.status_code == 200, usage_response.text
        assert usage_response.json()["models_used"] == 3
        assert mock_training_queue.submit_job.await_count == 3

    def test_free_tier_user_gets_403_on_fourth_training_job_attempt(
        self,
        client,
        auth_user,
        mock_training_queue,
    ) -> None:
        recording_ids = upload_recordings(client, auth_user["headers"], "quota-free-limit")

        for _ in range(3):
            response = client.post(
                "/api/training/start",
                headers=auth_user["headers"],
                json={"wake_word": "quota-free-limit", "recording_ids": recording_ids, "epochs": 5},
            )
            assert response.status_code == 202, response.text

        response = client.post(
            "/api/training/start",
            headers=auth_user["headers"],
            json={"wake_word": "quota-free-limit", "recording_ids": recording_ids, "epochs": 5},
        )

        assert response.status_code == 403, response.text
        assert "Monthly training limit reached" in response.json()["detail"]
        assert response.headers["X-Upgrade-URL"] == "/pricing"
        assert mock_training_queue.submit_job.await_count == 3

    def test_checkout_returns_checkout_url(self, client, auth_user, billing_settings) -> None:
        stripe = make_stripe_mock()
        stripe.Customer.create.return_value = SimpleNamespace(id="cus_checkout_123")
        stripe.checkout.Session.create.return_value = SimpleNamespace(
            id="cs_checkout_123",
            url="https://stripe.test/checkout/cs_checkout_123",
        )

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/checkout",
                headers=auth_user["headers"],
                json={"tier": "developer"},
            )

        assert response.status_code == 200, response.text
        assert response.json()["checkout_url"] == "https://stripe.test/checkout/cs_checkout_123"
        stripe.checkout.Session.create.assert_called_once()

    def test_checkout_without_auth_requires_auth(self, client, billing_settings) -> None:
        response = client.post("/api/billing/checkout", json={"tier": "developer"})

        assert response.status_code == 401, response.text
        assert response.json()["detail"] == "Not authenticated"


class TestBillingWebhooks:

    def test_webhook_checkout_session_completed_updates_subscription_tier(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_checkout_complete_{unique_suffix}"
        subscription_id = f"sub_checkout_complete_{unique_suffix}"
        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
        event = {
            "id": "evt_checkout_complete",
            "type": "checkout.session.completed",
            "data": {
                "object": {
                    "id": "cs_completed_123",
                    "customer": customer_id,
                    "subscription": subscription_id,
                    "metadata": {
                        "violawake_user_id": str(auth_user["user_id"]),
                        "tier": "developer",
                    },
                }
            },
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
        stripe.Subscription.retrieve.return_value = SimpleNamespace(current_period_end=period_end)

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )

        assert response.status_code == 200, response.text
        assert response.json() == {"status": "ok"}

        subscription_response = client.get(
            "/api/billing/subscription",
            headers=auth_user["headers"],
        )
        assert subscription_response.status_code == 200, subscription_response.text
        data = subscription_response.json()
        assert data["tier"] == "developer"
        assert data["status"] == "active"
        assert data["current_period_end"] is not None

        row = asyncio.run(get_subscription_row(auth_user["user_id"]))
        assert row is not None
        assert row["stripe_customer_id"] == customer_id
        assert row["stripe_subscription_id"] == subscription_id

    def test_webhook_subscription_deleted_downgrades_to_free(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_delete_{unique_suffix}"
        subscription_id = f"sub_delete_{unique_suffix}"
        asyncio.run(
            seed_subscription(
                user_id=auth_user["user_id"],
                tier="developer",
                status="active",
                stripe_customer_id=customer_id,
                stripe_subscription_id=subscription_id,
            )
        )

        event = {
            "id": "evt_subscription_deleted",
            "type": "customer.subscription.deleted",
            "data": {"object": {"id": subscription_id}},
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )

        assert response.status_code == 200, response.text

        subscription_response = client.get(
            "/api/billing/subscription",
            headers=auth_user["headers"],
        )
        assert subscription_response.status_code == 200, subscription_response.text
        data = subscription_response.json()
        assert data["tier"] == "free"
        assert data["status"] == "canceled"

        row = asyncio.run(get_subscription_row(auth_user["user_id"]))
        assert row is not None
        assert row["stripe_subscription_id"] is None
        assert row["current_period_end"] is None

    def test_invalid_webhook_signature_returns_400(
        self,
        client,
        billing_settings,
    ) -> None:
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.side_effect = stripe.error.SignatureVerificationError(
            "invalid signature"
        )

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_invalid"},
            )

        assert response.status_code == 400, response.text
        assert response.json()["detail"] == "Invalid webhook signature."

    def test_webhook_duplicate_event_id_is_processed_once(
        self,
        billing_settings,
    ) -> None:
        from app.routes import billing as billing_routes

        event = {
            "id": "evt_idempotency_direct_test",
            "type": "checkout.session.completed",
            "data": {
                "object": {
                    "id": "cs_idempotency_direct_test",
                    "customer": "cus_idempotency_direct_test",
                    "subscription": "sub_idempotency_direct_test",
                    "metadata": {"violawake_user_id": "123", "tier": "developer"},
                }
            },
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
        request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
        db = SimpleNamespace()
        checkout_handler = AsyncMock()

        billing_routes._processed_events.clear()
        try:
            with (
                patch("app.routes.billing._get_stripe", return_value=stripe),
                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
            ):
                first = asyncio.run(
                    billing_routes.stripe_webhook(
                        request,
                        db,
                        stripe_signature="sig_test_123",
                    )
                )
                second = asyncio.run(
                    billing_routes.stripe_webhook(
                        request,
                        db,
                        stripe_signature="sig_test_123",
                    )
                )
        finally:
            billing_routes._processed_events.clear()

        assert first == {"status": "ok"}
        assert second == {"status": "ok"}
        checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

 succeeded in 698ms:
"""Billing routes: Stripe checkout, webhook, subscription management, usage."""

import logging
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Annotated

from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import get_verified_user
from app.config import settings
from app.database import get_db
from app.models import Subscription, UsageRecord, User
from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
from app.schemas import (
    BillingPortalResponse,
    CheckoutRequest,
    CheckoutResponse,
    SubscriptionResponse,
    UsageResponse,
)

logger = logging.getLogger("violawake.billing")

router = APIRouter(prefix="/api/billing", tags=["billing"])

# ---------------------------------------------------------------------------
# Tier limits â€” single source of truth
# ---------------------------------------------------------------------------

TIER_LIMITS: dict[str, int | None] = {
    "free": 3,
    "developer": 20,
    "business": None,      # unlimited
    "enterprise": None,    # unlimited / custom
}

TIER_PRICE_MAP: dict[str, str] = {
    "developer": "stripe_price_developer",
    "business": "stripe_price_business",
}

# ---------------------------------------------------------------------------
# Webhook idempotency â€” deduplicate Stripe event deliveries within a single
# process lifetime.  Bounded to the most recent 1000 event IDs.
# ---------------------------------------------------------------------------

_PROCESSED_EVENTS_MAX = 1000
_processed_events: OrderedDict[str, None] = OrderedDict()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _require_billing() -> None:
    """Raise 503 if Stripe is not configured."""
    if not settings.billing_enabled:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Billing is not configured. Set VIOLAWAKE_STRIPE_SECRET_KEY to enable.",
        )


def _get_stripe():
    """Return the stripe module, configured with the secret key.

    Deferred import so the app starts even when stripe is not installed
    (e.g., in development without billing).
    """
    import stripe
    stripe.api_key = settings.stripe_secret_key
    return stripe


async def _verified_user_with_rate_key(
    request: Request,
    current_user: Annotated[User, Depends(get_verified_user)],
) -> User:
    """Resolve the verified user and stash the ID for per-user rate limiting."""
    set_rate_limit_user(request, current_user.id)
    return current_user


def _current_period_start() -> datetime:
    """Return the first instant of the current UTC month (billing period)."""
    now = datetime.now(timezone.utc)
    return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)


def _current_period_end() -> datetime:
    """Return the first instant of the next UTC month (end of billing period)."""
    now = datetime.now(timezone.utc)
    if now.month == 12:
        return now.replace(year=now.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
    return now.replace(month=now.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0)


def _price_id_for_tier(tier: str) -> str:
    """Resolve the Stripe Price ID for a tier, or raise if not configured."""
    attr = TIER_PRICE_MAP.get(tier)
    if attr is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Tier '{tier}' is not available for checkout.",
        )
    price_id = getattr(settings, attr, "")
    if not price_id:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Stripe price for '{tier}' tier is not configured.",
        )
    return price_id


async def _get_or_create_subscription(db: AsyncSession, user_id: int) -> Subscription:
    """Return the user's subscription row, creating a free-tier one if absent."""
    result = await db.execute(
        select(Subscription).where(Subscription.user_id == user_id)
    )
    sub = result.scalar_one_or_none()
    if sub is None:
        sub = Subscription(user_id=user_id, tier="free", status="active")
        db.add(sub)
        await db.flush()
    return sub


async def _get_usage_count(db: AsyncSession, user_id: int) -> int:
    """Return the number of training jobs started in the current billing period."""
    period_start = _current_period_start()
    result = await db.execute(
        select(UsageRecord).where(
            UsageRecord.user_id == user_id,
            UsageRecord.action == "training_job",
            UsageRecord.period_start == period_start,
        )
    )
    record = result.scalar_one_or_none()
    return record.count if record else 0


async def _get_or_create_stripe_customer(
    stripe, db: AsyncSession, user: User, sub: Subscription,
) -> str:
    """Ensure a Stripe Customer exists for this user. Return the customer ID."""
    if sub.stripe_customer_id:
        return sub.stripe_customer_id

    customer = stripe.Customer.create(
        email=user.email,
        name=user.name,
        metadata={"violawake_user_id": str(user.id)},
    )
    sub.stripe_customer_id = customer.id
    await db.flush()
    return customer.id


# ---------------------------------------------------------------------------
# Public helper: record_usage (called by training service)
# ---------------------------------------------------------------------------

async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
    """Increment the usage counter for the current billing period.

    Creates the UsageRecord row if it does not exist yet.

    Uses atomic SQL to avoid a read-modify-write race where two concurrent
    requests could both pass the quota check before either increments.
    """
    period_start = _current_period_start()

    # Attempt atomic UPDATE first: SET count = count + 1 in SQL, not Python.
    result = await db.execute(
        update(UsageRecord)
        .where(
            UsageRecord.user_id == user_id,
            UsageRecord.action == action,
            UsageRecord.period_start == period_start,
        )
        .values(count=UsageRecord.count + 1)
    )

    if result.rowcount == 0:
        # No existing row â€” insert one.  If a concurrent request races us and
        # inserts between our UPDATE and this INSERT, the unique constraint
        # (user_id, action, period_start) will raise IntegrityError; retry
        # with the atomic UPDATE which is now guaranteed to match.
        try:
            db.add(UsageRecord(
                user_id=user_id,
                action=action,
                period_start=period_start,
                count=1,
            ))
            await db.flush()
        except IntegrityError:
            await db.rollback()
            await db.execute(
                update(UsageRecord)
                .where(
                    UsageRecord.user_id == user_id,
                    UsageRecord.action == action,
                    UsageRecord.period_start == period_start,
                )
                .values(count=UsageRecord.count + 1)
            )
            await db.flush()
    else:
        await db.flush()


# ---------------------------------------------------------------------------
# Public helper: check_training_quota (dependency for training route)
# ---------------------------------------------------------------------------

async def check_training_quota(
    current_user: Annotated[User, Depends(get_verified_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
    """FastAPI dependency that enforces the training quota for the current user.

    Returns the User if quota is available; raises HTTP 403 if the monthly
    limit has been reached.
    """
    sub = await _get_or_create_subscription(db, current_user.id)
    limit = TIER_LIMITS.get(sub.tier)

    # None means unlimited
    if limit is None:
        return current_user

    used = await _get_usage_count(db, current_user.id)

    # Warn at 80% of limit (best-effort, non-blocking).
    if limit and used == int(limit * 0.8):
        try:
            from app.email_service import get_email_service

            email_svc = get_email_service()
            if email_svc.enabled:
                import asyncio

                asyncio.create_task(
                    email_svc.send_quota_warning(
                        to=current_user.email,
                        used=used,
                        limit=limit,
                        tier=sub.tier,
                    )
                )
        except Exception:
            pass  # non-critical â€” don't block training

    if used >= limit:
        tier_name = sub.tier.capitalize()
        if sub.tier == "free":
            upgrade_msg = "Upgrade to Developer for 20 models/month."
        elif sub.tier == "developer":
            upgrade_msg = "Upgrade to Business for unlimited models."
        else:
            upgrade_msg = "Contact sales for a custom plan."

        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=(
                f"Monthly training limit reached ({used}/{limit}). "
                f"You are on the {tier_name} plan. {upgrade_msg}"
            ),
            headers={"X-Upgrade-URL": "/pricing"},
        )

    return current_user


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

@router.post("/checkout", response_model=CheckoutResponse)
@limiter.limit(CHECKOUT_LIMIT, key_func=key_by_user)
async def create_checkout_session(
    request: Request,
    body: CheckoutRequest,
    current_user: Annotated[User, Depends(_verified_user_with_rate_key)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> CheckoutResponse:
    """Create a Stripe Checkout Session for upgrading to a paid tier."""
    _require_billing()
    stripe = _get_stripe()

    price_id = _price_id_for_tier(body.tier)
    sub = await _get_or_create_subscription(db, current_user.id)
    customer_id = await _get_or_create_stripe_customer(stripe, db, current_user, sub)

    # Prevent checkout if already on the requested tier (or higher)
    tier_rank = {"free": 0, "developer": 1, "business": 2, "enterprise": 3}
    if tier_rank.get(sub.tier, 0) >= tier_rank.get(body.tier, 0) and sub.status == "active":
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"You are already on the {sub.tier} plan (or higher).",
        )

    subscription_data: dict = {
        "metadata": {
            "violawake_user_id": str(current_user.id),
            "tier": body.tier,
        },
    }

    # Add free trial period if configured (VIOLAWAKE_TRIAL_DAYS, default 14, 0 to disable)
    if settings.trial_days > 0:
        subscription_data["trial_period_days"] = settings.trial_days
        logger.info(
            "Adding %d-day free trial to checkout for user %d, tier=%s",
            settings.trial_days, current_user.id, body.tier,
        )

    session = stripe.checkout.Session.create(
        customer=customer_id,
        mode="subscription",
        line_items=[{"price": price_id, "quantity": 1}],
        success_url=f"{settings.console_base_url}/billing?session_id={{CHECKOUT_SESSION_ID}}",
        cancel_url=f"{settings.console_base_url}/pricing",
        metadata={
            "violawake_user_id": str(current_user.id),
            "tier": body.tier,
        },
        subscription_data=subscription_data,
    )

    logger.info(
        "Checkout session created for user %d, tier=%s, session=%s",
        current_user.id, body.tier, session.id,
    )
    return CheckoutResponse(checkout_url=session.url)


@router.post("/webhook")
async def stripe_webhook(
    request: Request,
    db: Annotated[AsyncSession, Depends(get_db)],
    stripe_signature: Annotated[str | None, Header(alias="stripe-signature")] = None,
) -> dict:
    """Handle Stripe webhook events.

    This endpoint is called directly by Stripe -- it is NOT authenticated
    via JWT. Instead, the webhook signature is verified using the shared
    secret.
    """
    _require_billing()
    stripe = _get_stripe()

    if not settings.stripe_webhook_secret:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Webhook secret not configured.",
        )

    body = await request.body()

    if not stripe_signature:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Missing stripe-signature header.",
        )

    try:
        event = stripe.Webhook.construct_event(
            payload=body,
            sig_header=stripe_signature,
            secret=settings.stripe_webhook_secret,
        )
    except stripe.error.SignatureVerificationError:
        logger.warning("Webhook signature verification failed")
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Invalid webhook signature.",
        )

    # Deduplicate: Stripe may deliver the same event more than once.
    event_id = event.get("id")
    if event_id and event_id in _processed_events:
        logger.debug("Duplicate webhook event ignored: %s", event_id)
        return {"status": "ok"}
    if event_id:
        _processed_events[event_id] = None
        # Evict oldest entries to stay within the bound.
        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
            _processed_events.popitem(last=False)

    event_type = event["type"]
    data = event["data"]["object"]
    logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)

    if event_type == "checkout.session.completed":
        await _handle_checkout_completed(db, data)
    elif event_type == "customer.subscription.updated":
        await _handle_subscription_updated(db, data)
    elif event_type == "customer.subscription.deleted":
        await _handle_subscription_deleted(db, data)
    elif event_type == "invoice.payment_failed":
        await _handle_payment_failed(db, data)
    else:
        logger.debug("Ignoring unhandled webhook event type: %s", event_type)

    return {"status": "ok"}


@router.get("/subscription", response_model=SubscriptionResponse)
async def get_subscription(
    current_user: Annotated[User, Depends(get_verified_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> SubscriptionResponse:
    """Return the current user's subscription tier, status, and usage."""
    sub = await _get_or_create_subscription(db, current_user.id)
    used = await _get_usage_count(db, current_user.id)
    limit = TIER_LIMITS.get(sub.tier)

    # Fetch trial status from Stripe if subscription exists
    trial_active = False
    trial_end = None
    if sub.stripe_subscription_id and settings.billing_enabled:
        try:
            stripe = _get_stripe()
            stripe_sub = stripe.Subscription.retrieve(sub.stripe_subscription_id)
            if stripe_sub.status == "trialing" and stripe_sub.trial_end:
                trial_active = True
                trial_end = datetime.fromtimestamp(stripe_sub.trial_end, tz=timezone.utc)
        except Exception:
            logger.exception(
                "Failed to fetch trial status for subscription %s",
                sub.stripe_subscription_id,
            )

    return SubscriptionResponse(
        tier=sub.tier,
        status=sub.status,
        current_period_end=sub.current_period_end,
        trial_active=trial_active,
        trial_end=trial_end,
        usage=UsageResponse(
            models_used=used,
            models_limit=limit,
            period_start=_current_period_start(),
            period_end=_current_period_end(),
        ),
    )


@router.post("/portal", response_model=BillingPortalResponse)
@limiter.limit(PORTAL_LIMIT, key_func=key_by_user)
async def create_billing_portal(
    request: Request,
    current_user: Annotated[User, Depends(_verified_user_with_rate_key)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> BillingPortalResponse:
    """Create a Stripe Billing Portal session for managing the subscription."""
    _require_billing()
    stripe = _get_stripe()

    sub = await _get_or_create_subscription(db, current_user.id)
    if not sub.stripe_customer_id:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No billing account found. Subscribe to a plan first.",
        )

    session = stripe.billing_portal.Session.create(
        customer=sub.stripe_customer_id,
        return_url=f"{settings.console_base_url}/billing",
    )

    return BillingPortalResponse(url=session.url)


@router.get("/usage", response_model=UsageResponse)
async def get_usage(
    current_user: Annotated[User, Depends(get_verified_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> UsageResponse:
    """Return the current month's usage vs the tier limit."""
    sub = await _get_or_create_subscription(db, current_user.id)
    used = await _get_usage_count(db, current_user.id)
    limit = TIER_LIMITS.get(sub.tier)

    return UsageResponse(
        models_used=used,
        models_limit=limit,
        period_start=_current_period_start(),
        period_end=_current_period_end(),
    )


# ---------------------------------------------------------------------------
# Webhook event handlers
# ---------------------------------------------------------------------------

def _tier_from_price_id(price_id: str) -> str:
    """Resolve a Stripe Price ID back to a ViolaWake tier name."""
    if price_id == settings.stripe_price_developer:
        return "developer"
    if price_id == settings.stripe_price_business:
        return "business"
    logger.warning("Unknown Stripe price ID: %s â€” defaulting to 'developer'", price_id)
    return "developer"


async def _handle_checkout_completed(db: AsyncSession, session: dict) -> None:
    """Handle checkout.session.completed: create/update subscription."""
    metadata = session.get("metadata", {})
    user_id_str = metadata.get("violawake_user_id")
    tier = metadata.get("tier")
    customer_id = session.get("customer")
    subscription_id = session.get("subscription")

    if not user_id_str:
        logger.error("checkout.session.completed missing violawake_user_id in metadata: %s", session.get("id"))
        return

    user_id = int(user_id_str)
    sub = await _get_or_create_subscription(db, user_id)
    sub.stripe_customer_id = customer_id
    sub.stripe_subscription_id = subscription_id
    sub.tier = tier or "developer"
    sub.status = "active"

    # Fetch the subscription object to get current_period_end
    if subscription_id:
        try:
            stripe = _get_stripe()
            stripe_sub = stripe.Subscription.retrieve(subscription_id)
            sub.current_period_end = datetime.fromtimestamp(
                stripe_sub.current_period_end, tz=timezone.utc,
            )
        except Exception:
            logger.exception("Failed to fetch subscription %s for period end", subscription_id)

    await db.flush()
    logger.info(
        "Subscription activated: user=%d tier=%s stripe_sub=%s",
        user_id, sub.tier, subscription_id,
    )


async def _handle_subscription_updated(db: AsyncSession, subscription: dict) -> None:
    """Handle customer.subscription.updated: tier change, renewal, etc."""
    stripe_sub_id = subscription.get("id")
    result = await db.execute(
        select(Subscription).where(Subscription.stripe_subscription_id == stripe_sub_id)
    )
    sub = result.scalar_one_or_none()
    if sub is None:
        logger.warning("subscription.updated for unknown stripe_subscription_id=%s", stripe_sub_id)
        return

    # Update status
    stripe_status = subscription.get("status", "active")
    status_map = {
        "active": "active",
        "past_due": "past_due",
        "canceled": "canceled",
        "unpaid": "past_due",
        "incomplete": "past_due",
        "incomplete_expired": "canceled",
        "trialing": "active",
        "paused": "canceled",
    }
    sub.status = status_map.get(stripe_status, "active")

    # Update period end
    period_end = subscription.get("current_period_end")
    if period_end:
        sub.current_period_end = datetime.fromtimestamp(period_end, tz=timezone.utc)

    # Update tier from the subscription's price
    items = subscription.get("items", {}).get("data", [])
    if items:
        price_id = items[0].get("price", {}).get("id", "")
        if price_id:
            sub.tier = _tier_from_price_id(price_id)

    # Also check metadata for explicit tier override
    meta_tier = subscription.get("metadata", {}).get("tier")
    if meta_tier and meta_tier in TIER_LIMITS:
        sub.tier = meta_tier

    await db.flush()
    logger.info(
        "Subscription updated: user=%d tier=%s status=%s",
        sub.user_id, sub.tier, sub.status,
    )


async def _handle_subscription_deleted(db: AsyncSession, subscription: dict) -> None:
    """Handle customer.subscription.deleted: downgrade to free."""
    stripe_sub_id = subscription.get("id")
    result = await db.execute(
        select(Subscription).where(Subscription.stripe_subscription_id == stripe_sub_id)
    )
    sub = result.scalar_one_or_none()
    if sub is None:
        logger.warning("subscription.deleted for unknown stripe_subscription_id=%s", stripe_sub_id)
        return

    old_tier = sub.tier
    sub.tier = "free"
    sub.status = "canceled"
    sub.stripe_subscription_id = None
    sub.current_period_end = None
    await db.flush()
    logger.info(
        "Subscription deleted: user=%d downgraded from %s to free",
        sub.user_id, old_tier,
    )


async def _handle_payment_failed(db: AsyncSession, invoice: dict) -> None:
    """Handle invoice.payment_failed: mark subscription as past_due."""
    stripe_sub_id = invoice.get("subscription")
    if not stripe_sub_id:
        logger.debug("invoice.payment_failed without subscription ID, ignoring")
        return

    result = await db.execute(
        select(Subscription).where(Subscription.stripe_subscription_id == stripe_sub_id)
    )
    sub = result.scalar_one_or_none()
    if sub is None:
        logger.warning("payment_failed for unknown stripe_subscription_id=%s", stripe_sub_id)
        return

    sub.status = "past_due"
    await db.flush()
    logger.info(
        "Payment failed: user=%d subscription=%s set to past_due",
        sub.user_id, stripe_sub_id,
    )

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/models.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/alembic/env.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/alembic/versions/20260326_0001_a1b2c3d4e5f6_initial_schema.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/alembic/versions/20260328_0001_b2c3d4e5f6a7_add_teams.py' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 508ms:
"""Alembic environment configuration for ViolaWake Console.

Reads the database URL from app.config.settings so that the same
VIOLAWAKE_DB_URL / VIOLAWAKE_DB_PATH environment variables are used
for both the running application and schema migrations.

Supports both SQLite (dev) and PostgreSQL (production) via async engines.
"""
from __future__ import annotations

import asyncio
import logging
from logging.config import fileConfig

from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config

from app.config import settings
from app.database import Base

# Import all models so that Base.metadata is fully populated.
import app.models  # noqa: F401

# Alembic Config object â€” gives access to alembic.ini values.
config = context.config

# Set up Python logging from alembic.ini unless we are being called
# programmatically (e.g., from pytest) with an existing configuration.
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

logger = logging.getLogger("alembic.env")

# Target metadata for autogenerate support.
target_metadata = Base.metadata

# Resolve the database URL from application settings, not alembic.ini.
database_url = settings.database_url


def run_migrations_offline() -> None:
    """Run migrations in 'offline' mode.

    Configures the context with just a URL and not an engine.  Calls to
    context.execute() emit the given SQL string to the script output.
    """
    context.configure(
        url=database_url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection: Connection) -> None:
    """Run migrations with the given synchronous connection."""
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        # render_as_batch=True is required for SQLite, which does not support
        # ALTER TABLE ... DROP COLUMN natively.  It is harmless on PostgreSQL.
        render_as_batch=True,
    )

    with context.begin_transaction():
        context.run_migrations()


async def run_async_migrations() -> None:
    """Create an async engine and run migrations."""
    configuration = config.get_section(config.config_ini_section, {})
    configuration["sqlalchemy.url"] = database_url
    connectable = async_engine_from_config(
        configuration,
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)

    await connectable.dispose()


def run_migrations_online() -> None:
    """Run migrations in 'online' mode with an async engine."""
    asyncio.run(run_async_migrations())


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

 succeeded in 513ms:
"""Add teams and team_members tables, team_id FK on recordings and trained_models.

Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-03-28
"""
from __future__ import annotations

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "b2c3d4e5f6a7"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # --- teams ---
    op.create_table(
        "teams",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column("name", sa.String(255), nullable=False),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("owner_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False, index=True),
    )

    # --- team_members ---
    op.create_table(
        "team_members",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column("team_id", sa.Integer, sa.ForeignKey("teams.id"), nullable=False, index=True),
        sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False, index=True),
        sa.Column("role", sa.String(20), nullable=False, server_default=sa.text("'member'")),
        sa.Column("invited_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("joined_at", sa.DateTime(timezone=True), nullable=True),
        sa.UniqueConstraint("team_id", "user_id", name="uq_team_member"),
    )

    # --- Add nullable team_id FK to recordings ---
    op.add_column(
        "recordings",
        sa.Column("team_id", sa.Integer, sa.ForeignKey("teams.id"), nullable=True, index=True),
    )

    # --- Add nullable team_id FK to trained_models ---
    op.add_column(
        "trained_models",
        sa.Column("team_id", sa.Integer, sa.ForeignKey("teams.id"), nullable=True, index=True),
    )


def downgrade() -> None:
    op.drop_column("trained_models", "team_id")
    op.drop_column("recordings", "team_id")
    op.drop_table("team_members")
    op.drop_table("teams")

 succeeded in 676ms:
"""SQLAlchemy ORM models for ViolaWake Console."""

from __future__ import annotations

from datetime import datetime, timezone

from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database import Base


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
    password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
    failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)

    recordings: Mapped[list[Recording]] = relationship(back_populates="user", cascade="all, delete-orphan")
    training_jobs: Mapped[list[TrainingJob]] = relationship(back_populates="user", cascade="all, delete-orphan")
    trained_models: Mapped[list[TrainedModel]] = relationship(back_populates="user", cascade="all, delete-orphan")
    owned_teams: Mapped[list[Team]] = relationship(back_populates="owner", cascade="all, delete-orphan")
    team_memberships: Mapped[list[TeamMember]] = relationship(back_populates="user", cascade="all, delete-orphan")


class Team(Base):
    __tablename__ = "teams"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
    owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)

    owner: Mapped[User] = relationship(back_populates="owned_teams")
    members: Mapped[list[TeamMember]] = relationship(back_populates="team", cascade="all, delete-orphan")


class TeamMember(Base):
    __tablename__ = "team_members"
    __table_args__ = (
        UniqueConstraint("team_id", "user_id", name="uq_team_member"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    team_id: Mapped[int] = mapped_column(Integer, ForeignKey("teams.id"), nullable=False, index=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")  # owner / admin / member
    invited_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
    joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

    team: Mapped[Team] = relationship(back_populates="members")
    user: Mapped[User] = relationship(back_populates="team_memberships")


class Recording(Base):
    __tablename__ = "recordings"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    team_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("teams.id"), nullable=True, index=True)
    wake_word: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
    filename: Mapped[str] = mapped_column(String(255), nullable=False)
    file_path: Mapped[str] = mapped_column(String(1024), nullable=False)
    duration_s: Mapped[float] = mapped_column(Float, nullable=False)
    sample_rate: Mapped[int] = mapped_column(Integer, nullable=False, default=16000)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None)

    user: Mapped[User] = relationship(back_populates="recordings")


class TrainingJob(Base):
    __tablename__ = "training_jobs"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    wake_word: Mapped[str] = mapped_column(String(100), nullable=False)
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued")
    progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
    epochs: Mapped[int] = mapped_column(Integer, nullable=False, default=80)
    d_prime: Mapped[float | None] = mapped_column(Float, nullable=True)
    model_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("trained_models.id"), nullable=True)
    error: Mapped[str | None] = mapped_column(Text, nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
    completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

    user: Mapped[User] = relationship(back_populates="training_jobs")
    model: Mapped[TrainedModel | None] = relationship()


class TrainedModel(Base):
    __tablename__ = "trained_models"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    team_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("teams.id"), nullable=True, index=True)
    wake_word: Mapped[str] = mapped_column(String(100), nullable=False)
    file_path: Mapped[str] = mapped_column(String(1024), nullable=False)
    config_json: Mapped[str | None] = mapped_column(Text, nullable=True)
    d_prime: Mapped[float | None] = mapped_column(Float, nullable=True)
    size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)

    user: Mapped[User] = relationship(back_populates="trained_models")


class Subscription(Base):
    __tablename__ = "subscriptions"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True,
    )
    stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
    stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
    tier: Mapped[str] = mapped_column(String(20), nullable=False, default="free")
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
    current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)

    user: Mapped[User] = relationship(backref="subscription")


class UsageRecord(Base):
    __tablename__ = "usage_records"
    __table_args__ = (
        UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
    )
    action: Mapped[str] = mapped_column(String(50), nullable=False)
    period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)

 succeeded in 681ms:
"""Initial schema â€” all tables matching current ORM models.

Revision ID: a1b2c3d4e5f6
Revises:
Create Date: 2026-03-26
"""
from __future__ import annotations

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # --- users ---
    op.create_table(
        "users",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column("email", sa.String(255), unique=True, nullable=False, index=True),
        sa.Column("password_hash", sa.String(255), nullable=False),
        sa.Column("name", sa.String(255), nullable=False),
        sa.Column("email_verified", sa.Boolean, nullable=False, server_default=sa.text("0")),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
    )

    # --- recordings ---
    op.create_table(
        "recordings",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False, index=True),
        sa.Column("wake_word", sa.String(100), nullable=False, index=True),
        sa.Column("filename", sa.String(255), nullable=False),
        sa.Column("file_path", sa.String(1024), nullable=False),
        sa.Column("duration_s", sa.Float, nullable=False),
        sa.Column("sample_rate", sa.Integer, nullable=False, server_default=sa.text("16000")),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
    )

    # --- trained_models ---
    # Created before training_jobs because training_jobs has a FK to trained_models.
    op.create_table(
        "trained_models",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False, index=True),
        sa.Column("wake_word", sa.String(100), nullable=False),
        sa.Column("file_path", sa.String(1024), nullable=False),
        sa.Column("config_json", sa.Text, nullable=True),
        sa.Column("d_prime", sa.Float, nullable=True),
        sa.Column("size_bytes", sa.Integer, nullable=False, server_default=sa.text("0")),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
    )

    # --- training_jobs ---
    op.create_table(
        "training_jobs",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False, index=True),
        sa.Column("wake_word", sa.String(100), nullable=False),
        sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'queued'")),
        sa.Column("progress", sa.Float, nullable=False, server_default=sa.text("0.0")),
        sa.Column("epochs", sa.Integer, nullable=False, server_default=sa.text("50")),
        sa.Column("d_prime", sa.Float, nullable=True),
        sa.Column("model_id", sa.Integer, sa.ForeignKey("trained_models.id"), nullable=True),
        sa.Column("error", sa.Text, nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
    )

    # --- subscriptions ---
    op.create_table(
        "subscriptions",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column(
            "user_id",
            sa.Integer,
            sa.ForeignKey("users.id", ondelete="CASCADE"),
            unique=True,
            nullable=False,
            index=True,
        ),
        sa.Column("stripe_customer_id", sa.String(255), nullable=True, index=True),
        sa.Column("stripe_subscription_id", sa.String(255), nullable=True, unique=True),
        sa.Column("tier", sa.String(20), nullable=False, server_default=sa.text("'free'")),
        sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'active'")),
        sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
    )

    # --- usage_records ---
    op.create_table(
        "usage_records",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column(
            "user_id",
            sa.Integer,
            sa.ForeignKey("users.id", ondelete="CASCADE"),
            nullable=False,
            index=True,
        ),
        sa.Column("action", sa.String(50), nullable=False),
        sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
        sa.Column("count", sa.Integer, nullable=False, server_default=sa.text("0")),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
        sa.UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
    )


def downgrade() -> None:
    op.drop_table("usage_records")
    op.drop_table("subscriptions")
    op.drop_table("training_jobs")
    op.drop_table("trained_models")
    op.drop_table("recordings")
    op.drop_table("users")

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'rg -n "alembic upgrade head|entrypoint|upgrade head" console' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'rg -n "init_db|Base.metadata|database_url|db_url" console/backend/app console/tests' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-ChildItem console | Select-Object Name' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git status --short' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 419ms:
console/backend/app\config.py:44:    db_url: str = ""  # Optional full SQLAlchemy async URL, e.g. Railway PostgreSQL
console/backend/app\config.py:157:    def database_url(self) -> str:
console/backend/app\config.py:159:        if self.db_url and self.db_url.strip():
console/backend/app\config.py:160:            return self.db_url.strip()
console/backend/app\config.py:166:        if self.db_url and self.db_url.strip():
console/backend/app\database.py:18:    settings.db_url.strip()
console/backend/app\database.py:19:    if settings.db_url and settings.db_url.strip()
console/backend/app\database.py:93:async def init_db() -> None:
console/backend/app\database.py:96:        await conn.run_sync(Base.metadata.create_all)
console/backend/app\main.py:17:from app.database import init_db
console/backend/app\main.py:71:        await init_db()

 succeeded in 456ms:
console\Dockerfile.backend:5:# Install system deps for audio processing + gosu for entrypoint
console\Dockerfile.backend:29:COPY console/backend/entrypoint.sh /app/entrypoint.sh
console\Dockerfile.backend:30:RUN chmod +x /app/entrypoint.sh
console\Dockerfile.backend:34:ENTRYPOINT ["/app/entrypoint.sh"]

 succeeded in 503ms:

Name               
----               
.benchmarks        
backend            
frontend           
tests              
.coverage          
docker-compose.yml 
Dockerfile.backend 
Dockerfile.frontend
launch.py          
README.md          
run_e2e.py         



 succeeded in 1271ms:
?? .codex_log_a.txt
?? .codex_log_b.txt
?? .codex_log_c.txt
?? .codex_prompt_a.txt
?? .codex_prompt_b.txt
?? .codex_prompt_c.txt

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/entrypoint.sh' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/config.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/main.py' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 545ms:
#!/bin/sh
if [ "$(id -u)" = "0" ]; then
    chown -R app:app /app/data
    exec gosu app python run.py
else
    exec python run.py
fi

 succeeded in 567ms:
"""FastAPI application for ViolaWake Console backend."""

from __future__ import annotations

import asyncio
import logging
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded

from app.config import settings
from app.database import init_db
from app.health import router as health_router
from app.job_queue import init_job_queue, shutdown_job_queue
from app.middleware import (
    ErrorHandlingMiddleware,
    MaxBodySizeMiddleware,
    RequestLoggingMiddleware,
    SecurityHeadersMiddleware,
    configure_logging,
    init_sentry,
    register_exception_handlers,
)
from app.monitoring import APP_VERSION, init_monitoring_state, log_exception, mark_startup_complete
from app.rate_limit import limiter
from app.routes import auth, billing, files, jobs, models, recordings, teams, training

configure_logging()
logger = logging.getLogger("violawake.console")
init_sentry()

_RETENTION_INTERVAL_SECONDS = 24 * 60 * 60  # 24 hours


async def _retention_loop() -> None:
    """Run retention cleanup every 24 hours in the background."""
    from app.retention import (
        cleanup_expired_models,
        cleanup_expired_recordings,
        cleanup_soft_deleted_recordings,
    )

    while True:
        try:
            await cleanup_soft_deleted_recordings()
            await cleanup_expired_recordings()
            await cleanup_expired_models()
        except Exception as exc:
            log_exception(logger, exc, message="Retention cleanup cycle failed", source="retention")

        await asyncio.sleep(_RETENTION_INTERVAL_SECONDS)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """Application lifespan: initialize DB and mark the app ready."""
    init_monitoring_state(app)
    logger.info(
        "%s",
        "Starting ViolaWake Console backend",
        extra={"event_data": {"source": "startup", "database_target": settings.database_log_target}},
    )

    retention_task: asyncio.Task[None] | None = None
    try:
        await init_db()
        await init_job_queue()

        # Eagerly initialize the email service so the "Resend not configured"
        # warning is logged at startup rather than on the first registration.
        from app.email_service import get_email_service
        email_svc = get_email_service()
        if not email_svc.enabled:
            logger.warning(
                "Email service is disabled (VIOLAWAKE_RESEND_API_KEY not set). "
                "Users will be auto-verified on registration."
            )

        retention_task = asyncio.create_task(_retention_loop(), name="retention-cleanup")

        mark_startup_complete(app)
        logger.info(
            "%s",
            "ViolaWake Console backend ready",
            extra={"event_data": {"source": "startup", "version": APP_VERSION}},
        )
        yield
    except Exception as exc:
        log_exception(logger, exc, message="Application startup failed", source="startup")
        raise
    finally:
        if retention_task is not None:
            retention_task.cancel()
            with suppress(asyncio.CancelledError):
                await retention_task
        await shutdown_job_queue()
        logger.info("%s", "Shutting down ViolaWake Console backend", extra={"event_data": {"source": "shutdown"}})


app = FastAPI(
    title="ViolaWake Console",
    description="Backend API for ViolaWake wake word training console",
    version=APP_VERSION,
    lifespan=lifespan,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
init_monitoring_state(app)
register_exception_handlers(app)

app.add_middleware(ErrorHandlingMiddleware)
app.add_middleware(MaxBodySizeMiddleware)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.effective_cors_origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"],
)

app.include_router(health_router)
app.include_router(auth.router)
app.include_router(recordings.router)
app.include_router(jobs.router)
app.include_router(training.router)
app.include_router(models.router)
app.include_router(billing.router)
app.include_router(files.router)
app.include_router(teams.router)


# ---------------------------------------------------------------------------
# Admin endpoint: POST /api/admin/cleanup
# ---------------------------------------------------------------------------
# Guarded by VIOLAWAKE_ADMIN_TOKEN.  When the env var is not set (development
# default), the endpoint is disabled and returns 404 so it is never reachable
# without explicit configuration.
# ---------------------------------------------------------------------------

from fastapi import APIRouter, Depends, Header, HTTPException, status  # noqa: E402

_admin_router = APIRouter(prefix="/api/admin", tags=["admin"])


async def _require_admin(x_admin_token: str | None = Header(default=None)) -> None:
    """Dependency that verifies the X-Admin-Token header against config."""
    admin_token: str = getattr(settings, "admin_token", "")
    if not admin_token:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
    if not secrets.compare_digest(x_admin_token or "", admin_token):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")


@_admin_router.post("/cleanup")
async def trigger_cleanup(
    _: None = Depends(_require_admin),
) -> dict[str, int]:
    """Manually trigger retention cleanup for recordings and models.

    Requires ``VIOLAWAKE_ADMIN_TOKEN`` to be set in the environment.
    Protected by the ``X-Admin-Token`` request header.
    """
    from app.retention import (
        cleanup_expired_models,
        cleanup_expired_recordings,
        cleanup_soft_deleted_recordings,
    )

    soft_deleted_purged = await cleanup_soft_deleted_recordings()
    recordings_deleted = await cleanup_expired_recordings()
    models_deleted = await cleanup_expired_models()
    logger.info(
        "Admin cleanup triggered: %s soft-deleted recording(s) purged, %s recording(s) and %s model(s) deleted",
        soft_deleted_purged,
        recordings_deleted,
        models_deleted,
    )
    return {
        "soft_deleted_recordings_purged": soft_deleted_purged,
        "recordings_deleted": recordings_deleted,
        "models_deleted": models_deleted,
    }


app.include_router(_admin_router)

 succeeded in 653ms:
"""Application configuration for ViolaWake Console backend."""

from __future__ import annotations

import secrets
from pathlib import Path
from typing import Annotated, Any, Self

from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

DEFAULT_CORS_ORIGINS = [
    "http://localhost:5173",
    "http://127.0.0.1:5173",
]


def _generate_dev_secret_key() -> str:
    """Generate a development-only JWT key."""
    return secrets.token_urlsafe(32)


class Settings(BaseSettings):
    """Application settings loaded from environment / .env file."""

    # Environment
    env: str = "development"

    # Paths
    base_dir: Path = Path(__file__).resolve().parent.parent
    data_dir: Path = Path(__file__).resolve().parent.parent / "data"
    db_path: Path = Path(__file__).resolve().parent.parent / "data" / "violawake.db"
    upload_dir: Path = Path(__file__).resolve().parent.parent / "data" / "recordings"
    models_dir: Path = Path(__file__).resolve().parent.parent / "data" / "models"
    tmp_dir: Path = Path(__file__).resolve().parent.parent / "data" / "tmp"

    # Object storage
    r2_endpoint: str = ""
    r2_access_key_id: str = ""
    r2_secret_access_key: str = ""
    r2_bucket: str = "violawake"

    # Database
    db_url: str = ""  # Optional full SQLAlchemy async URL, e.g. Railway PostgreSQL

    # Auth
    secret_key: str = ""
    algorithm: str = "HS256"
    access_token_expire_hours: int = 2
    trusted_proxy_count: int = 0

    # CORS
    cors_origins: Annotated[list[str], NoDecode] = DEFAULT_CORS_ORIGINS.copy()

    # Training
    training_timeout: int = 1800  # seconds (30 minutes)
    max_concurrent_jobs: int = 2
    negatives_corpus_dir: str = ""  # Path to curated negative audio corpus (paid tier)

    # Retention cleanup (0 = disabled)
    recording_retention_days: int = 90  # Days to keep recordings; 0 disables automatic cleanup
    model_retention_days: int = 365  # Days to keep trained models; 0 disables automatic cleanup
    post_training_retention_hours: int = 24  # Hours to keep recordings after training completes; 0 disables

    # Admin
    admin_token: str = ""  # When set, enables POST /api/admin/cleanup (protect with a strong secret)

    # Stripe billing
    stripe_secret_key: str = ""
    stripe_webhook_secret: str = ""
    stripe_price_developer: str = ""  # Stripe Price ID for $29/mo Developer tier
    stripe_price_business: str = ""  # Stripe Price ID for $99/mo Business tier
    sentry_dsn: str = ""

    # Free trial
    trial_days: int = 14  # 0 to disable free trial for paid tiers

    # Console URLs (for Stripe checkout redirect)
    console_base_url: str = "http://localhost:5173"

    # Email
    resend_api_key: str = ""

    model_config = SettingsConfigDict(
        env_prefix="VIOLAWAKE_",
        env_file=".env",
        extra="ignore",
    )

    @field_validator("env", mode="before")
    @classmethod
    def normalize_env(cls, value: Any) -> str:
        """Accept mixed-case env names while keeping comparisons consistent."""
        if value is None:
            return "development"
        return str(value).strip().lower() or "development"

    @field_validator("cors_origins", mode="before")
    @classmethod
    def parse_cors_origins(cls, value: Any) -> Any:
        """Accept comma-separated CORS origins from env vars."""
        if isinstance(value, str):
            raw = value.strip()
            if not raw:
                return []
            if raw.startswith("["):
                return value
            return [origin.strip() for origin in raw.split(",") if origin.strip()]
        return value

    @model_validator(mode="after")
    def validate_production_settings(self) -> Self:
        """Resolve development defaults and enforce production requirements."""
        _MIN_SECRET_KEY_LENGTH = 32
        _INSECURE_PLACEHOLDERS = {"changeme", "secret", "password", "test", "dev"}

        key = self.secret_key.strip()
        key_is_empty = not key
        key_is_placeholder = key.lower() in _INSECURE_PLACEHOLDERS

        if key_is_empty or key_is_placeholder:
            if self.is_production:
                raise ValueError(
                    "VIOLAWAKE_SECRET_KEY must be set to a unique, random value "
                    "when VIOLAWAKE_ENV=production. "
                    "Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(64))\""
                )
            import logging
            _logger = logging.getLogger("violawake.config")
            self.secret_key = _generate_dev_secret_key()
            _logger.warning(
                "VIOLAWAKE_SECRET_KEY was empty or insecure â€” generated a random "
                "development key. DO NOT use this in production."
            )
        elif len(key) < _MIN_SECRET_KEY_LENGTH:
            if self.is_production:
                raise ValueError(
                    f"VIOLAWAKE_SECRET_KEY is too short ({len(key)} chars). "
                    f"Production requires at least {_MIN_SECRET_KEY_LENGTH} characters. "
                    "Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(64))\""
                )
            import logging
            _logger = logging.getLogger("violawake.config")
            _logger.warning(
                "VIOLAWAKE_SECRET_KEY is only %d characters (minimum %d recommended). "
                "Short keys are brute-forceable. This is acceptable for development only.",
                len(key),
                _MIN_SECRET_KEY_LENGTH,
            )
        return self

    @property
    def is_production(self) -> bool:
        return self.env == "production"

    @property
    def database_url(self) -> str:
        """Return the configured database URL, defaulting to local SQLite."""
        if self.db_url and self.db_url.strip():
            return self.db_url.strip()
        return f"sqlite+aiosqlite:///{self.db_path}"

    @property
    def database_log_target(self) -> str:
        """Return a safe database identifier for logs without leaking credentials."""
        if self.db_url and self.db_url.strip():
            return "VIOLAWAKE_DB_URL"
        return str(self.db_path)

    @property
    def effective_cors_origins(self) -> list[str]:
        """Return CORS origins.

        If ``cors_origins`` was explicitly set via env var, use exactly those.
        Otherwise fall back to sensible defaults based on the environment.
        """
        if self.cors_origins != DEFAULT_CORS_ORIGINS:
            return list(self.cors_origins)
        if self.is_production:
            return [
                "https://console.violawake.com",
                "https://violawake.com",
            ]
        return list(DEFAULT_CORS_ORIGINS)

    @property
    def billing_enabled(self) -> bool:
        """Billing features require a configured Stripe secret key."""
        return bool(self.stripe_secret_key)


settings = Settings()

# Ensure runtime directories exist for uploads, models, and the default SQLite path.
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.models_dir.mkdir(parents=True, exist_ok=True)
settings.tmp_dir.mkdir(parents=True, exist_ok=True)

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-ChildItem console/backend | Select-Object Name' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/run.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/Dockerfile.backend' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 595ms:
#!/usr/bin/env python3
"""Run the ViolaWake Console backend."""
import os

import uvicorn

if __name__ == "__main__":
    env = os.environ.get("VIOLAWAKE_ENV", "development")
    is_dev = env != "production"

    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=int(os.environ.get("VIOLAWAKE_PORT", "8000")),
        reload=is_dev,
    )

 succeeded in 648ms:
FROM python:3.11-slim

WORKDIR /app

# Install system deps for audio processing + gosu for entrypoint
RUN apt-get update && apt-get install -y --no-install-recommends \
    portaudio19-dev curl gosu \
    && rm -rf /var/lib/apt/lists/*

# Install Python deps + upgrade build tools
COPY console/backend/requirements.txt .
RUN pip install --no-cache-dir --upgrade pip hatchling && \
    pip install --no-cache-dir -r requirements.txt

# Install ViolaWake SDK
COPY pyproject.toml /sdk/
COPY README.md /sdk/
COPY LICENSE /sdk/
COPY src/ /sdk/src/
RUN pip install --no-cache-dir "/sdk[training]"

# Copy backend code
COPY console/backend/ .

# Create data directory and non-root user
RUN mkdir -p /app/data
RUN addgroup --system app && adduser --system --ingroup app app
RUN chown -R app:app /app /app/data
COPY console/backend/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh

EXPOSE 8000

ENTRYPOINT ["/app/entrypoint.sh"]

 succeeded in 1018ms:

Name            
----            
.benchmarks     
alembic         
app             
data            
scripts         
tests           
__pycache__     
.coverage       
.env            
.env.example    
alembic.ini     
entrypoint.sh   
requirements.txt
run.py          



codex
A deployment detail surfaced while reading: `console/backend/entrypoint.sh` currently just starts `python run.py`; I’m keeping that as evidence for the final deploy note instead of assuming Alembic is run there.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'rg -n "evt_|/api/billing/webhook|stripe_webhook|processed_stripe_events" console/tests console/backend/app console/backend/alembic' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 311ms:
console/tests\test_billing.py:189:    monkeypatch.setattr(settings, "stripe_webhook_secret", "whsec_test_123")
console/tests\test_billing.py:325:            "id": "evt_checkout_complete",
console/tests\test_billing.py:345:                "/api/billing/webhook",
console/tests\test_billing.py:388:            "id": "evt_subscription_deleted",
console/tests\test_billing.py:397:                "/api/billing/webhook",
console/tests\test_billing.py:430:                "/api/billing/webhook",
console/tests\test_billing.py:445:            "id": "evt_idempotency_direct_test",
console/tests\test_billing.py:469:                    billing_routes.stripe_webhook(
console/tests\test_billing.py:476:                    billing_routes.stripe_webhook(
console/backend/app\config.py:70:    stripe_webhook_secret: str = ""
console/backend/app\routes\billing.py:344:async def stripe_webhook(
console/backend/app\routes\billing.py:358:    if not settings.stripe_webhook_secret:
console/backend/app\routes\billing.py:376:            secret=settings.stripe_webhook_secret,

codex
I’m going to replace the in-process cache with a DB helper now. The helper will opportunistically delete old rows, then use one `INSERT ... ON CONFLICT DO NOTHING` result to decide whether the webhook should continue.
apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\models.py
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )

2026-05-07T05:09:34.681340Z ERROR codex_core::tools::router: error=apply_patch verification failed: Failed to find expected lines in J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py:
# Webhook idempotency â€” deduplicate Stripe event deliveries within a single
# process lifetime.  Bounded to the most recent 1000 event IDs.
# ---------------------------------------------------------------------------

_PROCESSED_EVENTS_MAX = 1000
_processed_events: OrderedDict[str, None] = OrderedDict()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/routes/billing.py -TotalCount 70' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 3722ms:
"""Billing routes: Stripe checkout, webhook, subscription management, usage."""

import logging
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Annotated

from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import get_verified_user
from app.config import settings
from app.database import get_db
from app.models import Subscription, UsageRecord, User
from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
from app.schemas import (
    BillingPortalResponse,
    CheckoutRequest,
    CheckoutResponse,
    SubscriptionResponse,
    UsageResponse,
)

logger = logging.getLogger("violawake.billing")

router = APIRouter(prefix="/api/billing", tags=["billing"])

# ---------------------------------------------------------------------------
# Tier limits â€” single source of truth
# ---------------------------------------------------------------------------

TIER_LIMITS: dict[str, int | None] = {
    "free": 3,
    "developer": 20,
    "business": None,      # unlimited
    "enterprise": None,    # unlimited / custom
}

TIER_PRICE_MAP: dict[str, str] = {
    "developer": "stripe_price_developer",
    "business": "stripe_price_business",
}

# ---------------------------------------------------------------------------
# Webhook idempotency â€” deduplicate Stripe event deliveries within a single
# process lifetime.  Bounded to the most recent 1000 event IDs.
# ---------------------------------------------------------------------------

_PROCESSED_EVENTS_MAX = 1000
_processed_events: OrderedDict[str, None] = OrderedDict()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _require_billing() -> None:
    """Raise 503 if Stripe is not configured."""
    if not settings.billing_enabled:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Billing is not configured. Set VIOLAWAKE_STRIPE_SECRET_KEY to enable.",
        )


def _get_stripe():
    """Return the stripe module, configured with the secret key.


diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..69637d56e7f57b5e0d695ee93f445dc52e1c1b4c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..69637d56e7f57b5e0d695ee93f445dc52e1c1b4c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,

2026-05-07T05:09:54.642564Z ERROR codex_core::tools::router: error=apply_patch verification failed: Failed to find expected lines in J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py:
# Webhook idempotency â€” deduplicate Stripe event deliveries within a single
# process lifetime.  Bounded to the most recent 1000 event IDs.
# ---------------------------------------------------------------------------

_PROCESSED_EVENTS_MAX = 1000
_processed_events: OrderedDict[str, None] = OrderedDict()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..69637d56e7f57b5e0d695ee93f445dc52e1c1b4c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..b470e2c95f9c1bd81f206c475b1bf8bdfc63fd4f
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -47,10 +46,6 @@
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
 # process lifetime.  Bounded to the most recent 1000 event IDs.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..b470e2c95f9c1bd81f206c475b1bf8bdfc63fd4f
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -47,10 +46,6 @@
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
 # process lifetime.  Bounded to the most recent 1000 event IDs.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..81e1207f5b3c98831c516f2a017496310ea26e5c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -45,12 +44,8 @@
 
 # ---------------------------------------------------------------------------
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..81e1207f5b3c98831c516f2a017496310ea26e5c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -45,12 +44,8 @@
 
 # ---------------------------------------------------------------------------
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

2026-05-07T05:10:06.070268Z ERROR codex_core::tools::router: error=apply_patch verification failed: Failed to find expected lines in J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py:
# Webhook idempotency â€” deduplicate Stripe event deliveries within a single
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..81e1207f5b3c98831c516f2a017496310ea26e5c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -45,12 +44,8 @@
 
 # ---------------------------------------------------------------------------
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/routes/billing.py -TotalCount 55' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 486ms:
"""Billing routes: Stripe checkout, webhook, subscription management, usage."""

import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated

from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy import delete, select, text, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import get_verified_user
from app.config import settings
from app.database import get_db
from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
from app.schemas import (
    BillingPortalResponse,
    CheckoutRequest,
    CheckoutResponse,
    SubscriptionResponse,
    UsageResponse,
)

logger = logging.getLogger("violawake.billing")

router = APIRouter(prefix="/api/billing", tags=["billing"])

# ---------------------------------------------------------------------------
# Tier limits â€” single source of truth
# ---------------------------------------------------------------------------

TIER_LIMITS: dict[str, int | None] = {
    "free": 3,
    "developer": 20,
    "business": None,      # unlimited
    "enterprise": None,    # unlimited / custom
}

TIER_PRICE_MAP: dict[str, str] = {
    "developer": "stripe_price_developer",
    "business": "stripe_price_business",
}

# ---------------------------------------------------------------------------
# Webhook idempotency â€” deduplicate Stripe event deliveries within a single
# restart do not re-run billing side effects.
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _require_billing() -> None:
    """Raise 503 if Stripe is not configured."""

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..81e1207f5b3c98831c516f2a017496310ea26e5c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -45,12 +44,8 @@
 
 # ---------------------------------------------------------------------------
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

2026-05-07T05:10:15.188434Z ERROR codex_core::tools::router: error=apply_patch verification failed: Failed to find expected lines in J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py:
# Webhook idempotency â€” deduplicate Stripe event deliveries within a single
# restart do not re-run billing side effects.
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..81e1207f5b3c98831c516f2a017496310ea26e5c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -45,12 +44,8 @@
 
 # ---------------------------------------------------------------------------
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Select-String -Path console/backend/app/routes/billing.py -Pattern "Webhook idempotency"' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 449ms:

console\backend\app\routes\billing.py:46:# Webhook idempotency — deduplicate Stripe event deliveries within a single



diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..81e1207f5b3c98831c516f2a017496310ea26e5c
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -45,12 +44,8 @@
 
 # ---------------------------------------------------------------------------
 # Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..c9f5c43f416ffaba2546f9807809cecfb553b6e6
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..c9f5c43f416ffaba2546f9807809cecfb553b6e6
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..e6cea4f9bd6c8dbe0a41f6dcd8481f1b23a59f73
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..e6cea4f9bd6c8dbe0a41f6dcd8481f1b23a59f73
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\app\routes\billing.py
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 

diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\backend\alembic\versions\20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\tests\test_billing.py
diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..baac49b27242273316d26a22fb037f4a6d02275a
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..baac49b27242273316d26a22fb037f4a6d02275a
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:

apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\tests\test_billing.py
diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/tests/test_billing.py | Select-Object -Skip 200 -First 310' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/routes/billing.py | Select-Object -Skip 35 -First 150' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/routes/billing.py | Select-Object -Skip 370 -First 55' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/models.py | Select-Object -Last 35' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 1478ms:
    stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
    tier: Mapped[str] = mapped_column(String(20), nullable=False, default="free")
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
    current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)

    user: Mapped[User] = relationship(backref="subscription")


class UsageRecord(Base):
    __tablename__ = "usage_records"
    __table_args__ = (
        UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
    )
    action: Mapped[str] = mapped_column(String(50), nullable=False)
    period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)


class ProcessedStripeEvent(Base):
    __tablename__ = "processed_stripe_events"

    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
    processed_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        server_default=func.now(),
    )

 succeeded in 1775ms:
    return CheckoutResponse(checkout_url=session.url)


@router.post("/webhook")
async def stripe_webhook(
    request: Request,
    db: Annotated[AsyncSession, Depends(get_db)],
    stripe_signature: Annotated[str | None, Header(alias="stripe-signature")] = None,
) -> dict:
    """Handle Stripe webhook events.

    This endpoint is called directly by Stripe -- it is NOT authenticated
    via JWT. Instead, the webhook signature is verified using the shared
    secret.
    """
    _require_billing()
    stripe = _get_stripe()

    if not settings.stripe_webhook_secret:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Webhook secret not configured.",
        )

    body = await request.body()

    if not stripe_signature:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Missing stripe-signature header.",
        )

    try:
        event = stripe.Webhook.construct_event(
            payload=body,
            sig_header=stripe_signature,
            secret=settings.stripe_webhook_secret,
        )
    except stripe.error.SignatureVerificationError:
        logger.warning("Webhook signature verification failed")
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Invalid webhook signature.",
        )

    # Deduplicate: Stripe may deliver the same event more than once.
    event_id = event.get("id")
    if not await _record_stripe_event_if_new(db, event_id):
        logger.debug("Duplicate webhook event ignored: %s", event_id)
        return {"status": "ok"}

    event_type = event["type"]
    data = event["data"]["object"]
    logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)


 succeeded in 2931ms:
    "business": None,      # unlimited
    "enterprise": None,    # unlimited / custom
}

TIER_PRICE_MAP: dict[str, str] = {
    "developer": "stripe_price_developer",
    "business": "stripe_price_business",
}

# ---------------------------------------------------------------------------
# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
# restart do not re-run billing side effects.
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _require_billing() -> None:
    """Raise 503 if Stripe is not configured."""
    if not settings.billing_enabled:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Billing is not configured. Set VIOLAWAKE_STRIPE_SECRET_KEY to enable.",
        )


def _get_stripe():
    """Return the stripe module, configured with the secret key.

    Deferred import so the app starts even when stripe is not installed
    (e.g., in development without billing).
    """
    import stripe
    stripe.api_key = settings.stripe_secret_key
    return stripe


async def _verified_user_with_rate_key(
    request: Request,
    current_user: Annotated[User, Depends(get_verified_user)],
) -> User:
    """Resolve the verified user and stash the ID for per-user rate limiting."""
    set_rate_limit_user(request, current_user.id)
    return current_user


def _current_period_start() -> datetime:
    """Return the first instant of the current UTC month (billing period)."""
    now = datetime.now(timezone.utc)
    return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)


def _current_period_end() -> datetime:
    """Return the first instant of the next UTC month (end of billing period)."""
    now = datetime.now(timezone.utc)
    if now.month == 12:
        return now.replace(year=now.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
    return now.replace(month=now.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0)


def _price_id_for_tier(tier: str) -> str:
    """Resolve the Stripe Price ID for a tier, or raise if not configured."""
    attr = TIER_PRICE_MAP.get(tier)
    if attr is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Tier '{tier}' is not available for checkout.",
        )
    price_id = getattr(settings, attr, "")
    if not price_id:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Stripe price for '{tier}' tier is not configured.",
        )
    return price_id


async def _get_or_create_subscription(db: AsyncSession, user_id: int) -> Subscription:
    """Return the user's subscription row, creating a free-tier one if absent."""
    result = await db.execute(
        select(Subscription).where(Subscription.user_id == user_id)
    )
    sub = result.scalar_one_or_none()
    if sub is None:
        sub = Subscription(user_id=user_id, tier="free", status="active")
        db.add(sub)
        await db.flush()
    return sub


async def _get_usage_count(db: AsyncSession, user_id: int) -> int:
    """Return the number of training jobs started in the current billing period."""
    period_start = _current_period_start()
    result = await db.execute(
        select(UsageRecord).where(
            UsageRecord.user_id == user_id,
            UsageRecord.action == "training_job",
            UsageRecord.period_start == period_start,
        )
    )
    record = result.scalar_one_or_none()
    return record.count if record else 0


async def _get_or_create_stripe_customer(
    stripe, db: AsyncSession, user: User, sub: Subscription,
) -> str:
    """Ensure a Stripe Customer exists for this user. Return the customer ID."""
    if sub.stripe_customer_id:
        return sub.stripe_customer_id

    customer = stripe.Customer.create(
        email=user.email,
        name=user.name,
        metadata={"violawake_user_id": str(user.id)},
    )
    sub.stripe_customer_id = customer.id
    await db.flush()
    return customer.id


async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
    """Delete processed Stripe event IDs older than 30 days."""
    bind = db.get_bind()
    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
    if dialect_name == "postgresql":
        await db.execute(
            text(
                "DELETE FROM processed_stripe_events "
                "WHERE processed_at < NOW() - INTERVAL '30 days'"
            )
        )
        return

    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
    await db.execute(
        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
    )


async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
    """Return True when this Stripe event ID has not already been processed."""
    await _cleanup_processed_stripe_events(db)
    if not event_id:
        return True

    result = await db.execute(
        text(
            "INSERT INTO processed_stripe_events (event_id) "

 succeeded in 3725ms:
    queue.submit_job = AsyncMock(side_effect=lambda **_: next(job_ids))

    with patch("app.routes.jobs.init_job_queue", new=AsyncMock(return_value=queue)):
        yield queue


def make_stripe_mock() -> MagicMock:
    """Build a Stripe mock with the members billing routes use."""

    class FakeSignatureVerificationError(Exception):
        pass

    stripe = MagicMock()
    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
    return stripe


class FakeExecuteResult:
    def __init__(self, rowcount: int) -> None:
        self.rowcount = rowcount


class FakeStripeEventSession:
    """AsyncSession stand-in for webhook idempotency unit tests."""

    def __init__(self) -> None:
        self.event_ids: set[str] = set()
        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []

    def get_bind(self) -> SimpleNamespace:
        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))

    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
        sql = str(statement)
        self.execute_calls.append((sql, params))

        if sql.startswith("DELETE FROM processed_stripe_events"):
            return FakeExecuteResult(rowcount=0)

        if sql.startswith("INSERT INTO processed_stripe_events"):
            assert params is not None
            event_id = str(params["event_id"])
            if event_id in self.event_ids:
                return FakeExecuteResult(rowcount=0)
            self.event_ids.add(event_id)
            return FakeExecuteResult(rowcount=1)

        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")


class TestBillingRoutes:

    def test_get_subscription_returns_free_tier_for_new_user(self, client, auth_user) -> None:
        response = client.get("/api/billing/subscription", headers=auth_user["headers"])

        assert response.status_code == 200, response.text
        data = response.json()
        assert data["tier"] == "free"
        assert data["status"] == "active"
        assert data["usage"]["models_used"] == 0
        assert data["usage"]["models_limit"] == 3

    def test_get_usage_returns_zero_for_new_user(self, client, auth_user) -> None:
        response = client.get("/api/billing/usage", headers=auth_user["headers"])

        assert response.status_code == 200, response.text
        data = response.json()
        assert data["models_used"] == 0
        assert data["models_limit"] == 3

    def test_free_tier_user_can_start_three_training_jobs(
        self,
        client,
        auth_user,
        mock_training_queue,
    ) -> None:
        recording_ids = upload_recordings(client, auth_user["headers"], "quota-free-ok")

        for _ in range(3):
            response = client.post(
                "/api/training/start",
                headers=auth_user["headers"],
                json={"wake_word": "quota-free-ok", "recording_ids": recording_ids, "epochs": 5},
            )
            assert response.status_code == 202, response.text

        usage_response = client.get("/api/billing/usage", headers=auth_user["headers"])
        assert usage_response.status_code == 200, usage_response.text
        assert usage_response.json()["models_used"] == 3
        assert mock_training_queue.submit_job.await_count == 3

    def test_free_tier_user_gets_403_on_fourth_training_job_attempt(
        self,
        client,
        auth_user,
        mock_training_queue,
    ) -> None:
        recording_ids = upload_recordings(client, auth_user["headers"], "quota-free-limit")

        for _ in range(3):
            response = client.post(
                "/api/training/start",
                headers=auth_user["headers"],
                json={"wake_word": "quota-free-limit", "recording_ids": recording_ids, "epochs": 5},
            )
            assert response.status_code == 202, response.text

        response = client.post(
            "/api/training/start",
            headers=auth_user["headers"],
            json={"wake_word": "quota-free-limit", "recording_ids": recording_ids, "epochs": 5},
        )

        assert response.status_code == 403, response.text
        assert "Monthly training limit reached" in response.json()["detail"]
        assert response.headers["X-Upgrade-URL"] == "/pricing"
        assert mock_training_queue.submit_job.await_count == 3

    def test_checkout_returns_checkout_url(self, client, auth_user, billing_settings) -> None:
        stripe = make_stripe_mock()
        stripe.Customer.create.return_value = SimpleNamespace(id="cus_checkout_123")
        stripe.checkout.Session.create.return_value = SimpleNamespace(
            id="cs_checkout_123",
            url="https://stripe.test/checkout/cs_checkout_123",
        )

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/checkout",
                headers=auth_user["headers"],
                json={"tier": "developer"},
            )

        assert response.status_code == 200, response.text
        assert response.json()["checkout_url"] == "https://stripe.test/checkout/cs_checkout_123"
        stripe.checkout.Session.create.assert_called_once()

    def test_checkout_without_auth_requires_auth(self, client, billing_settings) -> None:
        response = client.post("/api/billing/checkout", json={"tier": "developer"})

        assert response.status_code == 401, response.text
        assert response.json()["detail"] == "Not authenticated"


class TestBillingWebhooks:

    def test_webhook_checkout_session_completed_updates_subscription_tier(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_checkout_complete_{unique_suffix}"
        subscription_id = f"sub_checkout_complete_{unique_suffix}"
        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
        event = {
            "id": f"evt_checkout_complete_{unique_suffix}",
            "type": "checkout.session.completed",
            "data": {
                "object": {
                    "id": "cs_completed_123",
                    "customer": customer_id,
                    "subscription": subscription_id,
                    "metadata": {
                        "violawake_user_id": str(auth_user["user_id"]),
                        "tier": "developer",
                    },
                }
            },
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
        stripe.Subscription.retrieve.return_value = SimpleNamespace(current_period_end=period_end)

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )

        assert response.status_code == 200, response.text
        assert response.json() == {"status": "ok"}

        subscription_response = client.get(
            "/api/billing/subscription",
            headers=auth_user["headers"],
        )
        assert subscription_response.status_code == 200, subscription_response.text
        data = subscription_response.json()
        assert data["tier"] == "developer"
        assert data["status"] == "active"
        assert data["current_period_end"] is not None

        row = asyncio.run(get_subscription_row(auth_user["user_id"]))
        assert row is not None
        assert row["stripe_customer_id"] == customer_id
        assert row["stripe_subscription_id"] == subscription_id

    def test_webhook_subscription_deleted_downgrades_to_free(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_delete_{unique_suffix}"
        subscription_id = f"sub_delete_{unique_suffix}"
        asyncio.run(
            seed_subscription(
                user_id=auth_user["user_id"],
                tier="developer",
                status="active",
                stripe_customer_id=customer_id,
                stripe_subscription_id=subscription_id,
            )
        )

        event = {
            "id": f"evt_subscription_deleted_{unique_suffix}",
            "type": "customer.subscription.deleted",
            "data": {"object": {"id": subscription_id}},
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )

        assert response.status_code == 200, response.text

        subscription_response = client.get(
            "/api/billing/subscription",
            headers=auth_user["headers"],
        )
        assert subscription_response.status_code == 200, subscription_response.text
        data = subscription_response.json()
        assert data["tier"] == "free"
        assert data["status"] == "canceled"

        row = asyncio.run(get_subscription_row(auth_user["user_id"]))
        assert row is not None
        assert row["stripe_subscription_id"] is None
        assert row["current_period_end"] is None

    def test_invalid_webhook_signature_returns_400(
        self,
        client,
        billing_settings,
    ) -> None:
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.side_effect = stripe.error.SignatureVerificationError(
            "invalid signature"
        )

        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_invalid"},
            )

        assert response.status_code == 400, response.text
        assert response.json()["detail"] == "Invalid webhook signature."

    def test_webhook_duplicate_event_id_is_processed_once(
        self,
        billing_settings,
    ) -> None:
        from app.routes import billing as billing_routes

        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
        event = {
            "id": event_id,
            "type": "checkout.session.completed",
            "data": {
                "object": {
                    "id": "cs_idempotency_direct_test",
                    "customer": "cus_idempotency_direct_test",
                    "subscription": "sub_idempotency_direct_test",
                    "metadata": {"violawake_user_id": "123", "tier": "developer"},
                }
            },
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
        request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
        db = FakeStripeEventSession()
        checkout_handler = AsyncMock()

        with (
            patch("app.routes.billing._get_stripe", return_value=stripe),
            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
        ):
            first = asyncio.run(
                billing_routes.stripe_webhook(
                    request,
                    db,
                    stripe_signature="sig_test_123",
                )
            )
            second = asyncio.run(
                billing_routes.stripe_webhook(
                    request,
                    db,

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/tests/test_billing.py | Select-Object -Skip 500 -First 80' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 798ms:
                billing_routes.stripe_webhook(
                    request,
                    db,
                    stripe_signature="sig_test_123",
                )
            )
            second = asyncio.run(
                billing_routes.stripe_webhook(
                    request,
                    db,
                    stripe_signature="sig_test_123",
                )
            )

        assert first == {"status": "ok"}
        assert second == {"status": "ok"}
        assert db.event_ids == {event_id}
        assert sum(
            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
        ) == 2
        assert all(
            "processed_at < NOW() - INTERVAL '30 days'" in sql
            for sql, _ in db.execute_calls
            if sql.startswith("DELETE FROM processed_stripe_events")
        )
        checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/routes/billing.py | Select-Object -Skip 145 -First 45' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 1842ms:
        return sub.stripe_customer_id

    customer = stripe.Customer.create(
        email=user.email,
        name=user.name,
        metadata={"violawake_user_id": str(user.id)},
    )
    sub.stripe_customer_id = customer.id
    await db.flush()
    return customer.id


async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
    """Delete processed Stripe event IDs older than 30 days."""
    bind = db.get_bind()
    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
    if dialect_name == "postgresql":
        await db.execute(
            text(
                "DELETE FROM processed_stripe_events "
                "WHERE processed_at < NOW() - INTERVAL '30 days'"
            )
        )
        return

    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
    await db.execute(
        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
    )


async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
    """Return True when this Stripe event ID has not already been processed."""
    await _cleanup_processed_stripe_events(db)
    if not event_id:
        return True

    result = await db.execute(
        text(
            "INSERT INTO processed_stripe_events (event_id) "
            "VALUES (:event_id) "
            "ON CONFLICT (event_id) DO NOTHING"
        ),
        {"event_id": event_id},
    )

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/app/routes/billing.py | Select-Object -Skip 190 -First 15' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 1810ms:
    return result.rowcount != 0


# ---------------------------------------------------------------------------
# Public helper: record_usage (called by training service)
# ---------------------------------------------------------------------------

async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
    """Increment the usage counter for the current billing period.

    Creates the UsageRecord row if it does not exist yet.

    Uses atomic SQL to avoid a read-modify-write race where two concurrent
    requests could both pass the quota check before either increments.
    """

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
The patch is in place: model plus migration, route helper, and the direct duplicate-webhook unit test now uses a fake async session that simulates the Postgres `ON CONFLICT` rowcount behavior. I’m doing a quick diff sanity check before running pytest.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git diff -- console/backend/app/routes/billing.py console/backend/app/models.py console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py console/tests/test_billing.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git diff --check -- console/backend/app/routes/billing.py console/backend/app/models.py console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py console/tests/test_billing.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'Get-Content console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 770ms:
warning: in the working copy of 'console/backend/app/models.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/backend/app/routes/billing.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/tests/test_billing.py', LF will be replaced by CRLF the next time Git touches it

 succeeded in 898ms:
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b58278..44f22f6 100644
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@ from __future__ import annotations
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -146,3 +146,14 @@ class UsageRecord(Base):
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
     count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d9057..68f02b3 100644
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
 import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,14 +43,10 @@ TIER_PRICE_MAP: dict[str, str] = {
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
 
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
-
 # ---------------------------------------------------------------------------
 # Helpers
 # ---------------------------------------------------------------------------
@@ -160,6 +155,42 @@ async def _get_or_create_stripe_customer(
     return customer.id
 
 
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
 # ---------------------------------------------------------------------------
 # Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
@@ -384,14 +415,9 @@ async def stripe_webhook(
 
     # Deduplicate: Stripe may deliver the same event more than once.
     event_id = event.get("id")
-    if event_id and event_id in _processed_events:
+    if not await _record_stripe_event_if_new(db, event_id):
         logger.debug("Duplicate webhook event ignored: %s", event_id)
         return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
 
     event_type = event["type"]
     data = event["data"]["object"]
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fd..1e759db 100644
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -215,6 +215,39 @@ def make_stripe_mock() -> MagicMock:
     return stripe
 
 
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
+
+
 class TestBillingRoutes:
 
     def test_get_subscription_returns_free_tier_for_new_user(self, client, auth_user) -> None:
@@ -322,7 +355,7 @@ class TestBillingWebhooks:
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
         period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
         event = {
-            "id": "evt_checkout_complete",
+            "id": f"evt_checkout_complete_{unique_suffix}",
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -385,7 +418,7 @@ class TestBillingWebhooks:
         )
 
         event = {
-            "id": "evt_subscription_deleted",
+            "id": f"evt_subscription_deleted_{unique_suffix}",
             "type": "customer.subscription.deleted",
             "data": {"object": {"id": subscription_id}},
         }
@@ -441,8 +474,9 @@ class TestBillingWebhooks:
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@ class TestBillingWebhooks:
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])
warning: in the working copy of 'console/backend/app/models.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/backend/app/routes/billing.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/tests/test_billing.py', LF will be replaced by CRLF the next time Git touches it

 succeeded in 931ms:
"""Add processed_stripe_events table.

Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-05-07
"""
from __future__ import annotations

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, None] = "c3d4e5f6a7b8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    op.create_table(
        "processed_stripe_events",
        sa.Column("event_id", sa.Text, primary_key=True),
        sa.Column(
            "processed_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
    )


def downgrade() -> None:
    op.drop_table("processed_stripe_events")

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

  ✓ Inspect billing route, database setup, migrations, and tests
  ✓ Implement Postgres-backed Stripe event idempotency and migration
  ✓ Update webhook idempotency tests with mock-backed DB behavior
  → Run requested pytest commands and verify migration
  • Report files, SHAs/status, pytest summary, and deploy note
diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
The diff is clean apart from normal CRLF warnings. Next I’m running the focused billing tests from `console`, then the broader non-e2e suite summary.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'python -m pytest tests/test_billing.py --no-cov --timeout=60 -q' in J:\CLAUDE\PROJECTS\Wakeword\console
2026-05-07T05:12:54.674488Z ERROR codex_core::tools::router: error=Exit code: 1
Wall time: 25.9 seconds
Output:
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-8.4.1, pluggy-1.6.0
PySide6 6.11.0 -- Qt runtime 6.11.0 -- Qt compiled 6.11.0
rootdir: J:\CLAUDE\PROJECTS\Wakeword
configfile: pyproject.toml
plugins: anyio-4.12.0, hypothesis-6.148.7, asyncio-1.3.0, cov-7.0.0, qt-4.5.0, timeout-2.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
timeout: 60.0s
timeout method: thread
timeout func_only: False
collected 10 items

tests\test_billing.py ......FF..                                         [100%]

================================== FAILURES ===================================
_ TestBillingWebhooks.test_webhook_checkout_session_completed_updates_subscription_tier _

self = <tests.test_billing.TestBillingWebhooks object at 0x000001EFD79F9010>
client = <starlette.testclient.TestClient object at 0x000001EFD79FB590>
auth_user = {'email': 'billing_1778130771050984600@example.com', 'headers': {'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...iIxMDIxIiwiZXhwIjoxNzc4MTM3OTcxLCJpYXQiOjE3NzgxMzA3NzF9.Ze04hBdFCoQTZqfxe3-BY36L9_MX2jLEFnlw-PofX04'}, 'user_id': 1021}
billing_settings = Settings(env='development', base_dir=WindowsPath('J:/CLAUDE/PROJECTS/Wakeword/console/backend'), data_dir=WindowsPath(...iness='price_business_test', sentry_dsn='', trial_days=14, console_base_url='http://localhost:5173', resend_api_key='')

    def test_webhook_checkout_session_completed_updates_subscription_tier(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_checkout_complete_{unique_suffix}"
        subscription_id = f"sub_checkout_complete_{unique_suffix}"
        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
        event = {
            "id": f"evt_checkout_complete_{unique_suffix}",
            "type": "checkout.session.completed",
            "data": {
                "object": {
                    "id": "cs_completed_123",
                    "customer": customer_id,
                    "subscription": subscription_id,
                    "metadata": {
                        "violawake_user_id": str(auth_user["user_id"]),
                        "tier": "developer",
                    },
                }
            },
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
        stripe.Subscription.retrieve.return_value = SimpleNamespace(current_period_end=period_end)
    
        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )
    
>       assert response.status_code == 200, response.text
E       AssertionError: {"detail":"Internal server error","request_id":"31734472b53a42778191f5887ce7084d"}
E       assert 500 == 200
E        +  where 500 = <Response [500 Internal Server Error]>.status_code

tests\test_billing.py:383: AssertionError
----------------------------- Captured log setup ------------------------------
{"timestamp": "2026-05-07T05:12:51.340121+00:00", "level": "WARNING", "logger": "violawake.auth", "message": "Email service disabled \u2014 auto-verifying email for billing_1778130771050984600@example.com", "request_id": "03addbdb2d454a928c195c9e986b7140"}
{"timestamp": "2026-05-07T05:12:51.342625+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "03addbdb2d454a928c195c9e986b7140", "method": "POST", "path": "/api/auth/register", "route": "/api/auth/register", "status_code": 201, "duration_ms": 289.128}
{"timestamp": "2026-05-07T05:12:51.353843+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/register \"HTTP/1.1 201 Created\"", "request_id": "-"}
{"timestamp": "2026-05-07T05:12:51.619201+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "62779af6a01643eb89190900cf3f27a9", "method": "POST", "path": "/api/auth/login", "route": "/api/auth/login", "status_code": 200, "duration_ms": 258.766}
{"timestamp": "2026-05-07T05:12:51.620709+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/login \"HTTP/1.1 200 OK\"", "request_id": "-"}
------------------------------ Captured log call ------------------------------
{"timestamp": "2026-05-07T05:12:51.732153+00:00", "level": "ERROR", "logger": "violawake.console", "message": "Unhandled request exception", "request_id": "31734472b53a42778191f5887ce7084d", "source": "request", "error_kind": "unexpected", "error_reason": "bug", "error_type": "OperationalError", "error_message": "(sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:51.629543',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "exception": "Traceback (most recent call last):\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlite3.OperationalError: no such table: processed_stripe_events\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\middleware.py\", line 150, in dispatch\n    return await call_next(request)\n           ^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 168, in call_next\n    raise app_exc from app_exc.__cause__ or app_exc.__context__\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 144, in coro\n    await self.app(scope, receive_or_disconnect, send_no_error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\exceptions.py\", line 63, in __call__\n    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\middleware\\asyncexitstack.py\", line 18, in __call__\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 716, in __call__\n    await self.middleware_stack(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 736, in app\n    await route.handle(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 290, in handle\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 119, in app\n    await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 105, in app\n    response = await f(request)\n               ^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 426, in app\n    raw_response = await run_endpoint_function(\n                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 312, in run_endpoint_function\n    return await dependant.call(**values)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 418, in stripe_webhook\n    if not await _record_stripe_event_if_new(db, event_id):\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 179, in _record_stripe_event_if_new\n    await _cleanup_processed_stripe_events(db)\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 172, in _cleanup_processed_stripe_events\n    await db.execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\ext\\asyncio\\session.py\", line 449, in execute\n    result = await greenlet_spawn(\n             ^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 201, in greenlet_spawn\n    result = context.throw(*sys.exc_info())\n             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2351, in execute\n    return self._execute_internal(\n           ^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2249, in _execute_internal\n    result: Result[Any] = compile_state_cls.orm_execute_statement(\n                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\bulk_persistence.py\", line 2033, in orm_execute_statement\n    return super().orm_execute_statement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\context.py\", line 306, in orm_execute_statement\n    result = conn.execute(\n             ^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1419, in execute\n    return meth(\n           ^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\sql\\elements.py\", line 527, in _execute_on_connection\n    return connection._execute_clauseelement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1641, in _execute_clauseelement\n    ret = self._execute_context(\n          ^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1846, in _execute_context\n    return self._exec_single_context(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1986, in _exec_single_context\n    self._handle_dbapi_exception(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 2363, in _handle_dbapi_exception\n    raise sqlalchemy_exception.with_traceback(exc_info[2]) from e\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:51.629543',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)"}
{"timestamp": "2026-05-07T05:12:51.738532+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "31734472b53a42778191f5887ce7084d", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "status_code": 500, "duration_ms": 113.274}
{"timestamp": "2026-05-07T05:12:51.740532+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/billing/webhook \"HTTP/1.1 500 Internal Server Error\"", "request_id": "-"}
__ TestBillingWebhooks.test_webhook_subscription_deleted_downgrades_to_free ___

self = <tests.test_billing.TestBillingWebhooks object at 0x000001EFD79F96D0>
client = <starlette.testclient.TestClient object at 0x000001EFD79FB590>
auth_user = {'email': 'billing_1778130772719012000@example.com', 'headers': {'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...iIxMDIyIiwiZXhwIjoxNzc4MTM3OTczLCJpYXQiOjE3NzgxMzA3NzN9.IFDxB5NVqNcG4xGvHQNMs8U3GdK5q2pyyhxRPIpx_uc'}, 'user_id': 1022}
billing_settings = Settings(env='development', base_dir=WindowsPath('J:/CLAUDE/PROJECTS/Wakeword/console/backend'), data_dir=WindowsPath(...iness='price_business_test', sentry_dsn='', trial_days=14, console_base_url='http://localhost:5173', resend_api_key='')

    def test_webhook_subscription_deleted_downgrades_to_free(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_delete_{unique_suffix}"
        subscription_id = f"sub_delete_{unique_suffix}"
        asyncio.run(
            seed_subscription(
                user_id=auth_user["user_id"],
                tier="developer",
                status="active",
                stripe_customer_id=customer_id,
                stripe_subscription_id=subscription_id,
            )
        )
    
        event = {
            "id": f"evt_subscription_deleted_{unique_suffix}",
            "type": "customer.subscription.deleted",
            "data": {"object": {"id": subscription_id}},
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
    
        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )
    
>       assert response.status_code == 200, response.text
E       AssertionError: {"detail":"Internal server error","request_id":"20c75ef9e8ec420ea6b55b707642dc8f"}
E       assert 500 == 200
E        +  where 500 = <Response [500 Internal Server Error]>.status_code

tests\test_billing.py:435: AssertionError
----------------------------- Captured log setup ------------------------------
{"timestamp": "2026-05-07T05:12:52.999183+00:00", "level": "WARNING", "logger": "violawake.auth", "message": "Email service disabled \u2014 auto-verifying email for billing_1778130772719012000@example.com", "request_id": "f4fce9031983431ea712815e5c47d1de"}
{"timestamp": "2026-05-07T05:12:53.002121+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "f4fce9031983431ea712815e5c47d1de", "method": "POST", "path": "/api/auth/register", "route": "/api/auth/register", "status_code": 201, "duration_ms": 273.911}
{"timestamp": "2026-05-07T05:12:53.012369+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/register \"HTTP/1.1 201 Created\"", "request_id": "-"}
{"timestamp": "2026-05-07T05:12:53.325548+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "1f984122a59a451592187cbf96c587d1", "method": "POST", "path": "/api/auth/login", "route": "/api/auth/login", "status_code": 200, "duration_ms": 297.255}
{"timestamp": "2026-05-07T05:12:53.326549+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/login \"HTTP/1.1 200 OK\"", "request_id": "-"}
------------------------------ Captured log call ------------------------------
{"timestamp": "2026-05-07T05:12:53.357125+00:00", "level": "ERROR", "logger": "violawake.console", "message": "Unhandled request exception", "request_id": "20c75ef9e8ec420ea6b55b707642dc8f", "source": "request", "error_kind": "unexpected", "error_reason": "bug", "error_type": "OperationalError", "error_message": "(sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:53.342951',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "exception": "Traceback (most recent call last):\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlite3.OperationalError: no such table: processed_stripe_events\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\middleware.py\", line 150, in dispatch\n    return await call_next(request)\n           ^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 168, in call_next\n    raise app_exc from app_exc.__cause__ or app_exc.__context__\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 144, in coro\n    await self.app(scope, receive_or_disconnect, send_no_error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\exceptions.py\", line 63, in __call__\n    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\middleware\\asyncexitstack.py\", line 18, in __call__\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 716, in __call__\n    await self.middleware_stack(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 736, in app\n    await route.handle(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 290, in handle\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 119, in app\n    await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 105, in app\n    response = await f(request)\n               ^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 426, in app\n    raw_response = await run_endpoint_function(\n                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 312, in run_endpoint_function\n    return await dependant.call(**values)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 418, in stripe_webhook\n    if not await _record_stripe_event_if_new(db, event_id):\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 179, in _record_stripe_event_if_new\n    await _cleanup_processed_stripe_events(db)\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 172, in _cleanup_processed_stripe_events\n    await db.execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\ext\\asyncio\\session.py\", line 449, in execute\n    result = await greenlet_spawn(\n             ^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 201, in greenlet_spawn\n    result = context.throw(*sys.exc_info())\n             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2351, in execute\n    return self._execute_internal(\n           ^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2249, in _execute_internal\n    result: Result[Any] = compile_state_cls.orm_execute_statement(\n                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\bulk_persistence.py\", line 2033, in orm_execute_statement\n    return super().orm_execute_statement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\context.py\", line 306, in orm_execute_statement\n    result = conn.execute(\n             ^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1419, in execute\n    return meth(\n           ^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\sql\\elements.py\", line 527, in _execute_on_connection\n    return connection._execute_clauseelement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1641, in _execute_clauseelement\n    ret = self._execute_context(\n          ^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1846, in _execute_context\n    return self._exec_single_context(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1986, in _exec_single_context\n    self._handle_dbapi_exception(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 2363, in _handle_dbapi_exception\n    raise sqlalchemy_exception.with_traceback(exc_info[2]) from e\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:53.342951',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)"}
{"timestamp": "2026-05-07T05:12:53.363455+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "20c75ef9e8ec420ea6b55b707642dc8f", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "status_code": 500, "duration_ms": 23.014}
{"timestamp": "2026-05-07T05:12:53.364974+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/billing/webhook \"HTTP/1.1 500 Internal Server Error\"", "request_id": "-"}
=========================== short test summary info ===========================
FAILED tests\test_billing.py::TestBillingWebhooks::test_webhook_checkout_session_completed_updates_subscription_tier
FAILED tests\test_billing.py::TestBillingWebhooks::test_webhook_subscription_deleted_downgrades_to_free
======================== 2 failed, 8 passed in 18.74s =========================

 exited 1 in 25885ms:
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-8.4.1, pluggy-1.6.0
PySide6 6.11.0 -- Qt runtime 6.11.0 -- Qt compiled 6.11.0
rootdir: J:\CLAUDE\PROJECTS\Wakeword
configfile: pyproject.toml
plugins: anyio-4.12.0, hypothesis-6.148.7, asyncio-1.3.0, cov-7.0.0, qt-4.5.0, timeout-2.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
timeout: 60.0s
timeout method: thread
timeout func_only: False
collected 10 items

tests\test_billing.py ......FF..                                         [100%]

================================== FAILURES ===================================
_ TestBillingWebhooks.test_webhook_checkout_session_completed_updates_subscription_tier _

self = <tests.test_billing.TestBillingWebhooks object at 0x000001EFD79F9010>
client = <starlette.testclient.TestClient object at 0x000001EFD79FB590>
auth_user = {'email': 'billing_1778130771050984600@example.com', 'headers': {'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...iIxMDIxIiwiZXhwIjoxNzc4MTM3OTcxLCJpYXQiOjE3NzgxMzA3NzF9.Ze04hBdFCoQTZqfxe3-BY36L9_MX2jLEFnlw-PofX04'}, 'user_id': 1021}
billing_settings = Settings(env='development', base_dir=WindowsPath('J:/CLAUDE/PROJECTS/Wakeword/console/backend'), data_dir=WindowsPath(...iness='price_business_test', sentry_dsn='', trial_days=14, console_base_url='http://localhost:5173', resend_api_key='')

    def test_webhook_checkout_session_completed_updates_subscription_tier(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_checkout_complete_{unique_suffix}"
        subscription_id = f"sub_checkout_complete_{unique_suffix}"
        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
        event = {
            "id": f"evt_checkout_complete_{unique_suffix}",
            "type": "checkout.session.completed",
            "data": {
                "object": {
                    "id": "cs_completed_123",
                    "customer": customer_id,
                    "subscription": subscription_id,
                    "metadata": {
                        "violawake_user_id": str(auth_user["user_id"]),
                        "tier": "developer",
                    },
                }
            },
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
        stripe.Subscription.retrieve.return_value = SimpleNamespace(current_period_end=period_end)
    
        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )
    
>       assert response.status_code == 200, response.text
E       AssertionError: {"detail":"Internal server error","request_id":"31734472b53a42778191f5887ce7084d"}
E       assert 500 == 200
E        +  where 500 = <Response [500 Internal Server Error]>.status_code

tests\test_billing.py:383: AssertionError
----------------------------- Captured log setup ------------------------------
{"timestamp": "2026-05-07T05:12:51.340121+00:00", "level": "WARNING", "logger": "violawake.auth", "message": "Email service disabled \u2014 auto-verifying email for billing_1778130771050984600@example.com", "request_id": "03addbdb2d454a928c195c9e986b7140"}
{"timestamp": "2026-05-07T05:12:51.342625+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "03addbdb2d454a928c195c9e986b7140", "method": "POST", "path": "/api/auth/register", "route": "/api/auth/register", "status_code": 201, "duration_ms": 289.128}
{"timestamp": "2026-05-07T05:12:51.353843+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/register \"HTTP/1.1 201 Created\"", "request_id": "-"}
{"timestamp": "2026-05-07T05:12:51.619201+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "62779af6a01643eb89190900cf3f27a9", "method": "POST", "path": "/api/auth/login", "route": "/api/auth/login", "status_code": 200, "duration_ms": 258.766}
{"timestamp": "2026-05-07T05:12:51.620709+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/login \"HTTP/1.1 200 OK\"", "request_id": "-"}
------------------------------ Captured log call ------------------------------
{"timestamp": "2026-05-07T05:12:51.732153+00:00", "level": "ERROR", "logger": "violawake.console", "message": "Unhandled request exception", "request_id": "31734472b53a42778191f5887ce7084d", "source": "request", "error_kind": "unexpected", "error_reason": "bug", "error_type": "OperationalError", "error_message": "(sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:51.629543',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "exception": "Traceback (most recent call last):\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlite3.OperationalError: no such table: processed_stripe_events\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\middleware.py\", line 150, in dispatch\n    return await call_next(request)\n           ^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 168, in call_next\n    raise app_exc from app_exc.__cause__ or app_exc.__context__\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 144, in coro\n    await self.app(scope, receive_or_disconnect, send_no_error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\exceptions.py\", line 63, in __call__\n    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\middleware\\asyncexitstack.py\", line 18, in __call__\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 716, in __call__\n    await self.middleware_stack(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 736, in app\n    await route.handle(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 290, in handle\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 119, in app\n    await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 105, in app\n    response = await f(request)\n               ^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 426, in app\n    raw_response = await run_endpoint_function(\n                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 312, in run_endpoint_function\n    return await dependant.call(**values)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 418, in stripe_webhook\n    if not await _record_stripe_event_if_new(db, event_id):\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 179, in _record_stripe_event_if_new\n    await _cleanup_processed_stripe_events(db)\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 172, in _cleanup_processed_stripe_events\n    await db.execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\ext\\asyncio\\session.py\", line 449, in execute\n    result = await greenlet_spawn(\n             ^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 201, in greenlet_spawn\n    result = context.throw(*sys.exc_info())\n             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2351, in execute\n    return self._execute_internal(\n           ^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2249, in _execute_internal\n    result: Result[Any] = compile_state_cls.orm_execute_statement(\n                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\bulk_persistence.py\", line 2033, in orm_execute_statement\n    return super().orm_execute_statement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\context.py\", line 306, in orm_execute_statement\n    result = conn.execute(\n             ^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1419, in execute\n    return meth(\n           ^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\sql\\elements.py\", line 527, in _execute_on_connection\n    return connection._execute_clauseelement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1641, in _execute_clauseelement\n    ret = self._execute_context(\n          ^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1846, in _execute_context\n    return self._exec_single_context(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1986, in _exec_single_context\n    self._handle_dbapi_exception(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 2363, in _handle_dbapi_exception\n    raise sqlalchemy_exception.with_traceback(exc_info[2]) from e\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:51.629543',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)"}
{"timestamp": "2026-05-07T05:12:51.738532+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "31734472b53a42778191f5887ce7084d", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "status_code": 500, "duration_ms": 113.274}
{"timestamp": "2026-05-07T05:12:51.740532+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/billing/webhook \"HTTP/1.1 500 Internal Server Error\"", "request_id": "-"}
__ TestBillingWebhooks.test_webhook_subscription_deleted_downgrades_to_free ___

self = <tests.test_billing.TestBillingWebhooks object at 0x000001EFD79F96D0>
client = <starlette.testclient.TestClient object at 0x000001EFD79FB590>
auth_user = {'email': 'billing_1778130772719012000@example.com', 'headers': {'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...iIxMDIyIiwiZXhwIjoxNzc4MTM3OTczLCJpYXQiOjE3NzgxMzA3NzN9.IFDxB5NVqNcG4xGvHQNMs8U3GdK5q2pyyhxRPIpx_uc'}, 'user_id': 1022}
billing_settings = Settings(env='development', base_dir=WindowsPath('J:/CLAUDE/PROJECTS/Wakeword/console/backend'), data_dir=WindowsPath(...iness='price_business_test', sentry_dsn='', trial_days=14, console_base_url='http://localhost:5173', resend_api_key='')

    def test_webhook_subscription_deleted_downgrades_to_free(
        self,
        client,
        auth_user,
        billing_settings,
    ) -> None:
        unique_suffix = time.time_ns()
        customer_id = f"cus_delete_{unique_suffix}"
        subscription_id = f"sub_delete_{unique_suffix}"
        asyncio.run(
            seed_subscription(
                user_id=auth_user["user_id"],
                tier="developer",
                status="active",
                stripe_customer_id=customer_id,
                stripe_subscription_id=subscription_id,
            )
        )
    
        event = {
            "id": f"evt_subscription_deleted_{unique_suffix}",
            "type": "customer.subscription.deleted",
            "data": {"object": {"id": subscription_id}},
        }
        stripe = make_stripe_mock()
        stripe.Webhook.construct_event.return_value = event
    
        with patch("app.routes.billing._get_stripe", return_value=stripe):
            response = client.post(
                "/api/billing/webhook",
                content=b'{"test": true}',
                headers={"stripe-signature": "sig_test_123"},
            )
    
>       assert response.status_code == 200, response.text
E       AssertionError: {"detail":"Internal server error","request_id":"20c75ef9e8ec420ea6b55b707642dc8f"}
E       assert 500 == 200
E        +  where 500 = <Response [500 Internal Server Error]>.status_code

tests\test_billing.py:435: AssertionError
----------------------------- Captured log setup ------------------------------
{"timestamp": "2026-05-07T05:12:52.999183+00:00", "level": "WARNING", "logger": "violawake.auth", "message": "Email service disabled \u2014 auto-verifying email for billing_1778130772719012000@example.com", "request_id": "f4fce9031983431ea712815e5c47d1de"}
{"timestamp": "2026-05-07T05:12:53.002121+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "f4fce9031983431ea712815e5c47d1de", "method": "POST", "path": "/api/auth/register", "route": "/api/auth/register", "status_code": 201, "duration_ms": 273.911}
{"timestamp": "2026-05-07T05:12:53.012369+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/register \"HTTP/1.1 201 Created\"", "request_id": "-"}
{"timestamp": "2026-05-07T05:12:53.325548+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "1f984122a59a451592187cbf96c587d1", "method": "POST", "path": "/api/auth/login", "route": "/api/auth/login", "status_code": 200, "duration_ms": 297.255}
{"timestamp": "2026-05-07T05:12:53.326549+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/auth/login \"HTTP/1.1 200 OK\"", "request_id": "-"}
------------------------------ Captured log call ------------------------------
{"timestamp": "2026-05-07T05:12:53.357125+00:00", "level": "ERROR", "logger": "violawake.console", "message": "Unhandled request exception", "request_id": "20c75ef9e8ec420ea6b55b707642dc8f", "source": "request", "error_kind": "unexpected", "error_reason": "bug", "error_type": "OperationalError", "error_message": "(sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:53.342951',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "exception": "Traceback (most recent call last):\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlite3.OperationalError: no such table: processed_stripe_events\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\middleware.py\", line 150, in dispatch\n    return await call_next(request)\n           ^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 168, in call_next\n    raise app_exc from app_exc.__cause__ or app_exc.__context__\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\base.py\", line 144, in coro\n    await self.app(scope, receive_or_disconnect, send_no_error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\middleware\\exceptions.py\", line 63, in __call__\n    await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\middleware\\asyncexitstack.py\", line 18, in __call__\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 716, in __call__\n    await self.middleware_stack(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 736, in app\n    await route.handle(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\routing.py\", line 290, in handle\n    await self.app(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 119, in app\n    await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 53, in wrapped_app\n    raise exc\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\starlette\\_exception_handler.py\", line 42, in wrapped_app\n    await app(scope, receive, sender)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 105, in app\n    response = await f(request)\n               ^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 426, in app\n    raw_response = await run_endpoint_function(\n                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\fastapi\\routing.py\", line 312, in run_endpoint_function\n    return await dependant.call(**values)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 418, in stripe_webhook\n    if not await _record_stripe_event_if_new(db, event_id):\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 179, in _record_stripe_event_if_new\n    await _cleanup_processed_stripe_events(db)\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\routes\\billing.py\", line 172, in _cleanup_processed_stripe_events\n    await db.execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\ext\\asyncio\\session.py\", line 449, in execute\n    result = await greenlet_spawn(\n             ^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 201, in greenlet_spawn\n    result = context.throw(*sys.exc_info())\n             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2351, in execute\n    return self._execute_internal(\n           ^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\session.py\", line 2249, in _execute_internal\n    result: Result[Any] = compile_state_cls.orm_execute_statement(\n                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\bulk_persistence.py\", line 2033, in orm_execute_statement\n    return super().orm_execute_statement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\orm\\context.py\", line 306, in orm_execute_statement\n    result = conn.execute(\n             ^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1419, in execute\n    return meth(\n           ^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\sql\\elements.py\", line 527, in _execute_on_connection\n    return connection._execute_clauseelement(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1641, in _execute_clauseelement\n    ret = self._execute_context(\n          ^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1846, in _execute_context\n    return self._exec_single_context(\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1986, in _exec_single_context\n    self._handle_dbapi_exception(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 2363, in _handle_dbapi_exception\n    raise sqlalchemy_exception.with_traceback(exc_info[2]) from e\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\base.py\", line 1967, in _exec_single_context\n    self.dialect.do_execute(\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\engine\\default.py\", line 952, in do_execute\n    cursor.execute(statement, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 180, in execute\n    self._adapt_connection._handle_exception(error)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 340, in _handle_exception\n    raise error\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\dialects\\sqlite\\aiosqlite.py\", line 162, in execute\n    self.await_(_cursor.execute(operation, parameters))\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 132, in await_only\n    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\sqlalchemy\\util\\_concurrency_py3k.py\", line 196, in greenlet_spawn\n    value = await result\n            ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 40, in execute\n    await self._execute(self._cursor.execute, sql, parameters)\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\cursor.py\", line 32, in _execute\n    return await self._conn._execute(fn, *args, **kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 122, in _execute\n    return await future\n           ^^^^^^^^^^^^\n  File \"C:\\Users\\jihad\\viola-whisper\\Lib\\site-packages\\aiosqlite\\core.py\", line 105, in run\n    result = function()\n             ^^^^^^^^^^\nsqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: processed_stripe_events\n[SQL: DELETE FROM processed_stripe_events WHERE processed_stripe_events.processed_at < ?]\n[parameters: ('2026-04-07 05:12:53.342951',)]\n(Background on this error at: https://sqlalche.me/e/20/e3q8)"}
{"timestamp": "2026-05-07T05:12:53.363455+00:00", "level": "INFO", "logger": "violawake.request", "message": "request.complete", "request_id": "20c75ef9e8ec420ea6b55b707642dc8f", "method": "POST", "path": "/api/billing/webhook", "route": "/api/billing/webhook", "status_code": 500, "duration_ms": 23.014}
{"timestamp": "2026-05-07T05:12:53.364974+00:00", "level": "INFO", "logger": "httpx", "message": "HTTP Request: POST http://testserver/api/billing/webhook \"HTTP/1.1 500 Internal Server Error\"", "request_id": "-"}
=========================== short test summary info ===========================
FAILED tests\test_billing.py::TestBillingWebhooks::test_webhook_checkout_session_completed_updates_subscription_tier
FAILED tests\test_billing.py::TestBillingWebhooks::test_webhook_subscription_deleted_downgrades_to_free
======================== 2 failed, 8 passed in 18.74s =========================

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..1e759db09fa70022faeb39a7a4f815a17d516adb
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -204,15 +204,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +353,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +416,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +474,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +490,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
The focused run exposed a real test harness gap: this file’s `TestClient` fixture wasn’t entering FastAPI lifespan, so the existing SQLite test DB never got the new table. I’m fixing the billing test fixture to run the existing `init_db()` setup before constructing the client; that keeps the production route logic intact.
apply patch
patch: completed
J:\CLAUDE\PROJECTS\Wakeword\console\tests\test_billing.py
diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'python -m pytest tests/test_billing.py --no-cov --timeout=60 -q' in J:\CLAUDE\PROJECTS\Wakeword\console
 succeeded in 37933ms:
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-8.4.1, pluggy-1.6.0
PySide6 6.11.0 -- Qt runtime 6.11.0 -- Qt compiled 6.11.0
rootdir: J:\CLAUDE\PROJECTS\Wakeword
configfile: pyproject.toml
plugins: anyio-4.12.0, hypothesis-6.148.7, asyncio-1.3.0, cov-7.0.0, qt-4.5.0, timeout-2.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
timeout: 60.0s
timeout method: thread
timeout func_only: False
collected 10 items

tests\test_billing.py ..........                                         [100%]

============================= 10 passed in 23.29s =============================

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
Focused billing tests pass now. I’m running the broader non-e2e suite and will capture the final count from the tail.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'python -m pytest tests/ --no-cov --timeout=60 --ignore=tests/e2e -q' in J:\CLAUDE\PROJECTS\Wakeword\console
2026-05-07T05:14:57.853162Z ERROR codex_core::tools::router: error=Exit code: 1
Wall time: 54.1 seconds
Output:
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-8.4.1, pluggy-1.6.0
PySide6 6.11.0 -- Qt runtime 6.11.0 -- Qt compiled 6.11.0
rootdir: J:\CLAUDE\PROJECTS\Wakeword
configfile: pyproject.toml
plugins: anyio-4.12.0, hypothesis-6.148.7, asyncio-1.3.0, cov-7.0.0, qt-4.5.0, timeout-2.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
timeout: 60.0s
timeout method: thread
timeout func_only: False
collected 149 items

tests\test_auth_email_routes.py .....                                    [  3%]
tests\test_auth_security.py ......                                       [  7%]
tests\test_backend.py .................s...s.......                      [ 26%]
tests\test_billing.py ..........                                         [ 33%]
tests\test_health_monitoring.py ..............                           [ 42%]
tests\test_job_queue.py ...F.................................            [ 67%]
tests\test_max_body_size.py ..                                           [ 69%]
tests\test_retention.py ........                                         [ 74%]
tests\test_security_headers.py ..                                        [ 75%]
tests\test_storage.py ...........                                        [ 83%]
tests\test_teams.py .........................                            [100%]

================================== FAILURES ===================================
________________ TestSubmitAndGet.test_get_returns_pending_job ________________

self = <tests.test_job_queue.TestSubmitAndGet object at 0x0000023177CEA250>
tmp_path = WindowsPath('C:/Users/jihad/AppData/Local/Temp/pytest-of-jihad/pytest-98/test_get_returns_pending_job0')

    def test_get_returns_pending_job(self, tmp_path):
        async def _test(q):
            job_id = await _submit(q)
            job = await q.get_job(job_id)
            assert job is not None
            assert job.id == job_id
            assert job.status == JobStatus.PENDING
            assert job.wake_word == "test"
            assert job.user_id == 1
            assert job.recording_ids == [1, 2, 3, 4, 5]
            assert job.epochs == 10
            assert job.progress_pct == 0.0
>       _run_test(tmp_path, _test)

tests\test_job_queue.py:113: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests\test_job_queue.py:55: in _run_test
    loop.run_until_complete(_inner())
C:\Users\jihad\AppData\Local\Programs\Python\Python311\Lib\asyncio\base_events.py:654: in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
tests\test_job_queue.py:51: in _inner
    await coro_fn(q)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

q = <app.job_queue.JobQueue object at 0x00000231786EADD0>

    async def _test(q):
        job_id = await _submit(q)
        job = await q.get_job(job_id)
        assert job is not None
        assert job.id == job_id
>       assert job.status == JobStatus.PENDING
E       AssertionError: assert <JobStatus.RUNNING: 'running'> == <JobStatus.PENDING: 'pending'>
E         
E         - pending
E         + running

tests\test_job_queue.py:107: AssertionError
------------------------------ Captured log call ------------------------------
{"timestamp": "2026-05-07T05:14:46.148154+00:00", "level": "INFO", "logger": "violawake.jobs", "message": "Job queue started with max_concurrent=2", "request_id": "-"}
{"timestamp": "2026-05-07T05:14:46.177717+00:00", "level": "INFO", "logger": "violawake.jobs", "message": "Queued training job 1 for user 1 (priority=0)", "request_id": "-"}
{"timestamp": "2026-05-07T05:14:46.279116+00:00", "level": "ERROR", "logger": "violawake.jobs", "message": "Training job failed", "request_id": "-", "source": "job_queue", "error_kind": "unexpected", "error_reason": "bug", "error_type": "RuntimeError", "error_message": "No valid recordings found for training job 1", "job_id": 1, "exception": "Traceback (most recent call last):\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\job_queue.py\", line 702, in _execute_job\n    raise RuntimeError(f\"No valid recordings found for training job {job_id}\")\nRuntimeError: No valid recordings found for training job 1"}
=========================== short test summary info ===========================
FAILED tests\test_job_queue.py::TestSubmitAndGet::test_get_returns_pending_job
================== 1 failed, 146 passed, 2 skipped in 49.39s ==================

 exited 1 in 54138ms:
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-8.4.1, pluggy-1.6.0
PySide6 6.11.0 -- Qt runtime 6.11.0 -- Qt compiled 6.11.0
rootdir: J:\CLAUDE\PROJECTS\Wakeword
configfile: pyproject.toml
plugins: anyio-4.12.0, hypothesis-6.148.7, asyncio-1.3.0, cov-7.0.0, qt-4.5.0, timeout-2.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
timeout: 60.0s
timeout method: thread
timeout func_only: False
collected 149 items

tests\test_auth_email_routes.py .....                                    [  3%]
tests\test_auth_security.py ......                                       [  7%]
tests\test_backend.py .................s...s.......                      [ 26%]
tests\test_billing.py ..........                                         [ 33%]
tests\test_health_monitoring.py ..............                           [ 42%]
tests\test_job_queue.py ...F.................................            [ 67%]
tests\test_max_body_size.py ..                                           [ 69%]
tests\test_retention.py ........                                         [ 74%]
tests\test_security_headers.py ..                                        [ 75%]
tests\test_storage.py ...........                                        [ 83%]
tests\test_teams.py .........................                            [100%]

================================== FAILURES ===================================
________________ TestSubmitAndGet.test_get_returns_pending_job ________________

self = <tests.test_job_queue.TestSubmitAndGet object at 0x0000023177CEA250>
tmp_path = WindowsPath('C:/Users/jihad/AppData/Local/Temp/pytest-of-jihad/pytest-98/test_get_returns_pending_job0')

    def test_get_returns_pending_job(self, tmp_path):
        async def _test(q):
            job_id = await _submit(q)
            job = await q.get_job(job_id)
            assert job is not None
            assert job.id == job_id
            assert job.status == JobStatus.PENDING
            assert job.wake_word == "test"
            assert job.user_id == 1
            assert job.recording_ids == [1, 2, 3, 4, 5]
            assert job.epochs == 10
            assert job.progress_pct == 0.0
>       _run_test(tmp_path, _test)

tests\test_job_queue.py:113: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests\test_job_queue.py:55: in _run_test
    loop.run_until_complete(_inner())
C:\Users\jihad\AppData\Local\Programs\Python\Python311\Lib\asyncio\base_events.py:654: in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
tests\test_job_queue.py:51: in _inner
    await coro_fn(q)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

q = <app.job_queue.JobQueue object at 0x00000231786EADD0>

    async def _test(q):
        job_id = await _submit(q)
        job = await q.get_job(job_id)
        assert job is not None
        assert job.id == job_id
>       assert job.status == JobStatus.PENDING
E       AssertionError: assert <JobStatus.RUNNING: 'running'> == <JobStatus.PENDING: 'pending'>
E         
E         - pending
E         + running

tests\test_job_queue.py:107: AssertionError
------------------------------ Captured log call ------------------------------
{"timestamp": "2026-05-07T05:14:46.148154+00:00", "level": "INFO", "logger": "violawake.jobs", "message": "Job queue started with max_concurrent=2", "request_id": "-"}
{"timestamp": "2026-05-07T05:14:46.177717+00:00", "level": "INFO", "logger": "violawake.jobs", "message": "Queued training job 1 for user 1 (priority=0)", "request_id": "-"}
{"timestamp": "2026-05-07T05:14:46.279116+00:00", "level": "ERROR", "logger": "violawake.jobs", "message": "Training job failed", "request_id": "-", "source": "job_queue", "error_kind": "unexpected", "error_reason": "bug", "error_type": "RuntimeError", "error_message": "No valid recordings found for training job 1", "job_id": 1, "exception": "Traceback (most recent call last):\n  File \"J:\\CLAUDE\\PROJECTS\\Wakeword\\console\\backend\\app\\job_queue.py\", line 702, in _execute_job\n    raise RuntimeError(f\"No valid recordings found for training job {job_id}\")\nRuntimeError: No valid recordings found for training job 1"}
=========================== short test summary info ===========================
FAILED tests\test_job_queue.py::TestSubmitAndGet::test_get_returns_pending_job
================== 1 failed, 146 passed, 2 skipped in 49.39s ==================

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
The broader suite hit one unrelated race in `test_job_queue.py`: a queued job moved from `pending` to `running` before the assertion. I’m rerunning that specific test to determine whether it is a pre-existing flaky timing issue or something reproducible.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'python -m pytest tests/test_job_queue.py::TestSubmitAndGet::test_get_returns_pending_job --no-cov --timeout=60 -q' in J:\CLAUDE\PROJECTS\Wakeword\console
 succeeded in 11599ms:
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-8.4.1, pluggy-1.6.0
PySide6 6.11.0 -- Qt runtime 6.11.0 -- Qt compiled 6.11.0
rootdir: J:\CLAUDE\PROJECTS\Wakeword
configfile: pyproject.toml
plugins: anyio-4.12.0, hypothesis-6.148.7, asyncio-1.3.0, cov-7.0.0, qt-4.5.0, timeout-2.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
timeout: 60.0s
timeout method: thread
timeout func_only: False
collected 1 item

tests\test_job_queue.py .                                                [100%]

============================== 1 passed in 4.47s ==============================

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
The isolated job-queue test passed on rerun, so that failure is timing-sensitive. I’m rerunning the full non-e2e suite once to get the final requested summary.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'python -m pytest tests/ --no-cov --timeout=60 --ignore=tests/e2e -q' in J:\CLAUDE\PROJECTS\Wakeword\console
 succeeded in 60426ms:
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-8.4.1, pluggy-1.6.0
PySide6 6.11.0 -- Qt runtime 6.11.0 -- Qt compiled 6.11.0
rootdir: J:\CLAUDE\PROJECTS\Wakeword
configfile: pyproject.toml
plugins: anyio-4.12.0, hypothesis-6.148.7, asyncio-1.3.0, cov-7.0.0, qt-4.5.0, timeout-2.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
timeout: 60.0s
timeout method: thread
timeout func_only: False
collected 149 items

tests\test_auth_email_routes.py .....                                    [  3%]
tests\test_auth_security.py ......                                       [  7%]
tests\test_backend.py .................s...s.......                      [ 26%]
tests\test_billing.py ..........                                         [ 33%]
tests\test_health_monitoring.py ..............                           [ 42%]
tests\test_job_queue.py .....................................            [ 67%]
tests\test_max_body_size.py ..                                           [ 69%]
tests\test_retention.py ........                                         [ 74%]
tests\test_security_headers.py ..                                        [ 75%]
tests\test_storage.py ...........                                        [ 83%]
tests\test_teams.py .........................                            [100%]

======================= 147 passed, 2 skipped in 52.47s =======================

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'rg -n "def upgrade|def downgrade|processed_stripe_events|server_default" console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git status --short' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git diff --check -- console/backend/app/routes/billing.py console/backend/app/models.py console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py console/tests/test_billing.py' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 1314ms:
1:"""Add processed_stripe_events table.
21:def upgrade() -> None:
23:        "processed_stripe_events",
29:            server_default=sa.func.now(),
34:def downgrade() -> None:
35:    op.drop_table("processed_stripe_events")

 succeeded in 1546ms:
warning: in the working copy of 'console/backend/app/models.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/backend/app/routes/billing.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/tests/test_billing.py', LF will be replaced by CRLF the next time Git touches it

 succeeded in 1802ms:
 M console/backend/app/models.py
 M console/backend/app/routes/billing.py
 M console/tests/test_billing.py
?? .codex_log_a.txt
?? .codex_log_b.txt
?? .codex_log_c.txt
?? .codex_prompt_a.txt
?? .codex_prompt_b.txt
?? .codex_prompt_c.txt
?? .lighthouseci/
?? console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
Validation now passes on rerun: `147 passed, 2 skipped`. I’m going to create one explicit-file commit for the model, migration, route, and tests, leaving unrelated untracked files untouched.
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git add console/backend/app/models.py console/backend/app/routes/billing.py console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py console/tests/test_billing.py' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 377ms:
warning: in the working copy of 'console/backend/app/models.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/backend/app/routes/billing.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/tests/test_billing.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py', LF will be replaced by CRLF the next time Git touches it

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git commit -m "Persist Stripe webhook idempotency"' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 2267ms:
[master 3ed32ec] Persist Stripe webhook idempotency
 4 files changed, 154 insertions(+), 41 deletions(-)
 create mode 100644 console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git status --short' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git show --stat --oneline --name-only HEAD' in J:\CLAUDE\PROJECTS\Wakeword
exec
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -Command 'git rev-parse HEAD' in J:\CLAUDE\PROJECTS\Wakeword
 succeeded in 666ms:
3ed32ec Persist Stripe webhook idempotency
console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
console/backend/app/models.py
console/backend/app/routes/billing.py
console/tests/test_billing.py

 succeeded in 791ms:
?? .codex_log_a.txt
?? .codex_log_b.txt
?? .codex_log_c.txt
?? .codex_prompt_a.txt
?? .codex_prompt_b.txt
?? .codex_prompt_c.txt
?? .lighthouseci/

 succeeded in 799ms:
3ed32ec086db36b75501d490f16edb4a4cc6bfcc

diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

codex
Implemented and committed.

Files changed:
- [models.py](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/app/models.py>)
- [billing.py](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/app/routes/billing.py>)
- [20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py>)
- [test_billing.py](</J:/CLAUDE/PROJECTS/Wakeword/console/tests/test_billing.py>)

Commit:
- `3ed32ec086db36b75501d490f16edb4a4cc6bfcc` - `Persist Stripe webhook idempotency`

What changed:
- Added `ProcessedStripeEvent` SQLAlchemy model.
- Replaced `_processed_events` in-memory `OrderedDict` with DB-backed `INSERT ... ON CONFLICT (event_id) DO NOTHING`.
- Added opportunistic 30-day cleanup before event insert.
- Added additive Alembic migration with both `upgrade()` and `downgrade()`.
- Updated billing tests to use unique webhook event IDs and a fake async DB session for duplicate-event idempotency, so no real Postgres is required.

Validation:
- `python -m pytest tests/test_billing.py --no-cov --timeout=60 -q`: `10 passed`
- `python -m pytest tests/ --no-cov --timeout=60 --ignore=tests/e2e -q`: `147 passed, 2 skipped`

Deploy note: I checked [entrypoint.sh](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/entrypoint.sh>) and it does **not** run `alembic upgrade head`; it only execs `python run.py`. So the next backend deploy still needs `alembic upgrade head` run by the deploy pipeline or entrypoint before serving.
diff --git a/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b595282cc3379753bdd5bf783a195e8306d1e1
--- /dev/null
+++ b/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py
@@ -0,0 +1,35 @@
+"""Add processed_stripe_events table.
+
+Revision ID: d4e5f6a7b8c9
+Revises: c3d4e5f6a7b8
+Create Date: 2026-05-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "d4e5f6a7b8c9"
+down_revision: Union[str, None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "processed_stripe_events",
+        sa.Column("event_id", sa.Text, primary_key=True),
+        sa.Column(
+            "processed_at",
+            sa.DateTime(timezone=True),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("processed_stripe_events")
diff --git a/console/backend/app/models.py b/console/backend/app/models.py
index 1b5827859440cf3955af51173586037448eb0027..44f22f6af31356491d68ca6177dc3fe7185211c6
--- a/console/backend/app/models.py
+++ b/console/backend/app/models.py
@@ -4,7 +4,7 @@
 
 from datetime import datetime, timezone
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from app.database import Base
@@ -132,7 +132,7 @@
     user: Mapped[User] = relationship(backref="subscription")
 
 
-class UsageRecord(Base):
+class UsageRecord(Base):
     __tablename__ = "usage_records"
     __table_args__ = (
         UniqueConstraint("user_id", "action", "period_start", name="uq_usage_user_action_period"),
@@ -144,5 +144,16 @@
     )
     action: Mapped[str] = mapped_column(String(50), nullable=False)
     period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
-    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+    count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
+
+
+class ProcessedStripeEvent(Base):
+    __tablename__ = "processed_stripe_events"
+
+    event_id: Mapped[str] = mapped_column(Text, primary_key=True)
+    processed_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        server_default=func.now(),
+    )
diff --git a/console/backend/app/routes/billing.py b/console/backend/app/routes/billing.py
index 60d905763a6e9e3cb90e172d35b06569893598f1..68f02b3ef2df60764d251979fb6ed02536b6effd
--- a/console/backend/app/routes/billing.py
+++ b/console/backend/app/routes/billing.py
@@ -1,19 +1,18 @@
 """Billing routes: Stripe checkout, webhook, subscription management, usage."""
 
-import logging
-from collections import OrderedDict
-from datetime import datetime, timezone
+import logging
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
 from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from sqlalchemy import select, update
+from sqlalchemy import delete, select, text, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from app.auth import get_verified_user
 from app.config import settings
 from app.database import get_db
-from app.models import Subscription, UsageRecord, User
+from app.models import ProcessedStripeEvent, Subscription, UsageRecord, User
 from app.rate_limit import CHECKOUT_LIMIT, PORTAL_LIMIT, key_by_user, limiter, set_rate_limit_user
 from app.schemas import (
     BillingPortalResponse,
@@ -44,13 +43,9 @@
 }
 
 # ---------------------------------------------------------------------------
-# Webhook idempotency — deduplicate Stripe event deliveries within a single
-# process lifetime.  Bounded to the most recent 1000 event IDs.
+# Webhook idempotency: persist Stripe event IDs so redeliveries after a backend
+# restart do not re-run billing side effects.
 # ---------------------------------------------------------------------------
-
-_PROCESSED_EVENTS_MAX = 1000
-_processed_events: OrderedDict[str, None] = OrderedDict()
-
 
 # ---------------------------------------------------------------------------
 # Helpers
@@ -155,13 +150,49 @@
         name=user.name,
         metadata={"violawake_user_id": str(user.id)},
     )
-    sub.stripe_customer_id = customer.id
-    await db.flush()
-    return customer.id
-
-
-# ---------------------------------------------------------------------------
-# Public helper: record_usage (called by training service)
+    sub.stripe_customer_id = customer.id
+    await db.flush()
+    return customer.id
+
+
+async def _cleanup_processed_stripe_events(db: AsyncSession) -> None:
+    """Delete processed Stripe event IDs older than 30 days."""
+    bind = db.get_bind()
+    dialect_name = getattr(getattr(bind, "dialect", None), "name", "")
+    if dialect_name == "postgresql":
+        await db.execute(
+            text(
+                "DELETE FROM processed_stripe_events "
+                "WHERE processed_at < NOW() - INTERVAL '30 days'"
+            )
+        )
+        return
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
+    await db.execute(
+        delete(ProcessedStripeEvent).where(ProcessedStripeEvent.processed_at < cutoff)
+    )
+
+
+async def _record_stripe_event_if_new(db: AsyncSession, event_id: str | None) -> bool:
+    """Return True when this Stripe event ID has not already been processed."""
+    await _cleanup_processed_stripe_events(db)
+    if not event_id:
+        return True
+
+    result = await db.execute(
+        text(
+            "INSERT INTO processed_stripe_events (event_id) "
+            "VALUES (:event_id) "
+            "ON CONFLICT (event_id) DO NOTHING"
+        ),
+        {"event_id": event_id},
+    )
+    return result.rowcount != 0
+
+
+# ---------------------------------------------------------------------------
+# Public helper: record_usage (called by training service)
 # ---------------------------------------------------------------------------
 
 async def record_usage(db: AsyncSession, user_id: int, action: str = "training_job") -> None:
@@ -382,18 +413,13 @@
             detail="Invalid webhook signature.",
         )
 
-    # Deduplicate: Stripe may deliver the same event more than once.
-    event_id = event.get("id")
-    if event_id and event_id in _processed_events:
-        logger.debug("Duplicate webhook event ignored: %s", event_id)
-        return {"status": "ok"}
-    if event_id:
-        _processed_events[event_id] = None
-        # Evict oldest entries to stay within the bound.
-        while len(_processed_events) > _PROCESSED_EVENTS_MAX:
-            _processed_events.popitem(last=False)
-
-    event_type = event["type"]
+    # Deduplicate: Stripe may deliver the same event more than once.
+    event_id = event.get("id")
+    if not await _record_stripe_event_if_new(db, event_id):
+        logger.debug("Duplicate webhook event ignored: %s", event_id)
+        return {"status": "ok"}
+
+    event_type = event["type"]
     data = event["data"]["object"]
     logger.info("Stripe webhook received: %s (id=%s)", event_type, event_id)
 
diff --git a/console/tests/test_billing.py b/console/tests/test_billing.py
index 5b1c1fdd1e48bcc6300a30d2e449bef2aaf624b9..d7013b1fefb1cb596eda59cfaaf6c61f10412548
--- a/console/tests/test_billing.py
+++ b/console/tests/test_billing.py
@@ -166,11 +166,13 @@
 @pytest.fixture(scope="module")
 def client():
     """Create a FastAPI test client."""
-    try:
-        from app.main import app
-    except ImportError as exc:
-        pytest.skip(f"Backend not yet built: {exc}")
-
+    try:
+        from app.main import app
+        from app.database import init_db
+    except ImportError as exc:
+        pytest.skip(f"Backend not yet built: {exc}")
+
+    asyncio.run(init_db())
     return TestClient(app)
 
 
@@ -204,15 +206,48 @@
         yield queue
 
 
-def make_stripe_mock() -> MagicMock:
-    """Build a Stripe mock with the members billing routes use."""
-
-    class FakeSignatureVerificationError(Exception):
-        pass
-
-    stripe = MagicMock()
-    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
-    return stripe
+def make_stripe_mock() -> MagicMock:
+    """Build a Stripe mock with the members billing routes use."""
+
+    class FakeSignatureVerificationError(Exception):
+        pass
+
+    stripe = MagicMock()
+    stripe.error.SignatureVerificationError = FakeSignatureVerificationError
+    return stripe
+
+
+class FakeExecuteResult:
+    def __init__(self, rowcount: int) -> None:
+        self.rowcount = rowcount
+
+
+class FakeStripeEventSession:
+    """AsyncSession stand-in for webhook idempotency unit tests."""
+
+    def __init__(self) -> None:
+        self.event_ids: set[str] = set()
+        self.execute_calls: list[tuple[str, dict[str, object] | None]] = []
+
+    def get_bind(self) -> SimpleNamespace:
+        return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
+
+    async def execute(self, statement, params: dict[str, object] | None = None) -> FakeExecuteResult:
+        sql = str(statement)
+        self.execute_calls.append((sql, params))
+
+        if sql.startswith("DELETE FROM processed_stripe_events"):
+            return FakeExecuteResult(rowcount=0)
+
+        if sql.startswith("INSERT INTO processed_stripe_events"):
+            assert params is not None
+            event_id = str(params["event_id"])
+            if event_id in self.event_ids:
+                return FakeExecuteResult(rowcount=0)
+            self.event_ids.add(event_id)
+            return FakeExecuteResult(rowcount=1)
+
+        raise AssertionError(f"Unexpected SQL in fake billing session: {sql}")
 
 
 class TestBillingRoutes:
@@ -320,11 +355,11 @@
         unique_suffix = time.time_ns()
         customer_id = f"cus_checkout_complete_{unique_suffix}"
         subscription_id = f"sub_checkout_complete_{unique_suffix}"
-        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
-        event = {
-            "id": "evt_checkout_complete",
-            "type": "checkout.session.completed",
-            "data": {
+        period_end = int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
+        event = {
+            "id": f"evt_checkout_complete_{unique_suffix}",
+            "type": "checkout.session.completed",
+            "data": {
                 "object": {
                     "id": "cs_completed_123",
                     "customer": customer_id,
@@ -383,11 +418,11 @@
                 stripe_subscription_id=subscription_id,
             )
         )
-
-        event = {
-            "id": "evt_subscription_deleted",
-            "type": "customer.subscription.deleted",
-            "data": {"object": {"id": subscription_id}},
+
+        event = {
+            "id": f"evt_subscription_deleted_{unique_suffix}",
+            "type": "customer.subscription.deleted",
+            "data": {"object": {"id": subscription_id}},
         }
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
@@ -441,8 +476,9 @@
     ) -> None:
         from app.routes import billing as billing_routes
 
+        event_id = f"evt_idempotency_direct_test_{time.time_ns()}"
         event = {
-            "id": "evt_idempotency_direct_test",
+            "id": event_id,
             "type": "checkout.session.completed",
             "data": {
                 "object": {
@@ -456,32 +492,37 @@
         stripe = make_stripe_mock()
         stripe.Webhook.construct_event.return_value = event
         request = SimpleNamespace(body=AsyncMock(return_value=b'{"test": true}'))
-        db = SimpleNamespace()
+        db = FakeStripeEventSession()
         checkout_handler = AsyncMock()
 
-        billing_routes._processed_events.clear()
-        try:
-            with (
-                patch("app.routes.billing._get_stripe", return_value=stripe),
-                patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
-            ):
-                first = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+        with (
+            patch("app.routes.billing._get_stripe", return_value=stripe),
+            patch("app.routes.billing._handle_checkout_completed", new=checkout_handler),
+        ):
+            first = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-                second = asyncio.run(
-                    billing_routes.stripe_webhook(
-                        request,
-                        db,
-                        stripe_signature="sig_test_123",
-                    )
+            )
+            second = asyncio.run(
+                billing_routes.stripe_webhook(
+                    request,
+                    db,
+                    stripe_signature="sig_test_123",
                 )
-        finally:
-            billing_routes._processed_events.clear()
+            )
 
         assert first == {"status": "ok"}
         assert second == {"status": "ok"}
+        assert db.event_ids == {event_id}
+        assert sum(
+            1 for sql, _ in db.execute_calls if sql.startswith("INSERT INTO processed_stripe_events")
+        ) == 2
+        assert all(
+            "processed_at < NOW() - INTERVAL '30 days'" in sql
+            for sql, _ in db.execute_calls
+            if sql.startswith("DELETE FROM processed_stripe_events")
+        )
         checkout_handler.assert_awaited_once_with(db, event["data"]["object"])

SUCCESS: The process with PID 68016 (child process of PID 86124) has been terminated.
SUCCESS: The process with PID 86124 (child process of PID 44984) has been terminated.
tokens used
171,910
Implemented and committed.

Files changed:
- [models.py](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/app/models.py>)
- [billing.py](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/app/routes/billing.py>)
- [20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/alembic/versions/20260507_0001_d4e5f6a7b8c9_add_processed_stripe_events_table.py>)
- [test_billing.py](</J:/CLAUDE/PROJECTS/Wakeword/console/tests/test_billing.py>)

Commit:
- `3ed32ec086db36b75501d490f16edb4a4cc6bfcc` - `Persist Stripe webhook idempotency`

What changed:
- Added `ProcessedStripeEvent` SQLAlchemy model.
- Replaced `_processed_events` in-memory `OrderedDict` with DB-backed `INSERT ... ON CONFLICT (event_id) DO NOTHING`.
- Added opportunistic 30-day cleanup before event insert.
- Added additive Alembic migration with both `upgrade()` and `downgrade()`.
- Updated billing tests to use unique webhook event IDs and a fake async DB session for duplicate-event idempotency, so no real Postgres is required.

Validation:
- `python -m pytest tests/test_billing.py --no-cov --timeout=60 -q`: `10 passed`
- `python -m pytest tests/ --no-cov --timeout=60 --ignore=tests/e2e -q`: `147 passed, 2 skipped`

Deploy note: I checked [entrypoint.sh](</J:/CLAUDE/PROJECTS/Wakeword/console/backend/entrypoint.sh>) and it does **not** run `alembic upgrade head`; it only execs `python run.py`. So the next backend deploy still needs `alembic upgrade head` run by the deploy pipeline or entrypoint before serving.
