Developers
SubPrune API reference.
The API is a Hono service. On the public web origin it is proxied at /backend, so calls are same-origin: /backend/detect. CORS is restricted to the web app, so the proxy is the supported path. Every JSON error shares one shape: {"error": "…", "code": "…"}.
Session endpoints (Better Auth)
- POST
/api/auth/sign-up/emailNo authCreate an account with email and password (Better Auth).
- Response:
- 200 session created
- POST
/api/auth/sign-in/emailNo authSign in with email and password (Better Auth).
- Response:
- 200 session created
- POST
/api/auth/sign-in/socialNo authSign in with Google (available when Google credentials are configured).
- Response:
- 200 session created
- GET
/api/auth/get-sessionNo authReturn the current session, if any (Better Auth).
- Response:
- 200 `{"session":…,"user":…}` or `{"session":null,"user":null}`
- POST
/api/auth/sign-outNo authEnd the session (Better Auth).
- Response:
- 200 signed out
Product endpoints
- GET
/healthNo authReadiness probe. Reports the names (never values) of missing env vars.
- Response:
- 200 `{"status":"ok"}` or 503 `{"status":"degraded","missingEnv":["…"]}`
- GET
/No authService identity.
- Response:
- 200 `{"status":"ok","service":"subprune-api"}`
- POST
/detectSession cookieRun Detection on mapped Statement rows and return Subscription candidates. Rows are never persisted. Send `Accept: application/x-ndjson` to stream one JSON line per finished stage instead.
- Request:
- `{"rows":[{"date":"2026-01-03","description":"Netflix","amount":15.49}]}`
- Response:
- 200 `{"subscriptions":[{"merchant_name":"Netflix","amount":15.49,"frequency":"monthly","next_renewal_date":"2026-02-03","source":"llm"}],"row_categories":[{"row_index":1,"category_name":"Groceries"}]}`
- Errors:
- 400 invalid body; 401 unauthenticated; 503 Detection provider unavailable
- POST
/statements/extractSession cookieExtract a Statement (CSV or text-backed PDF, multipart `file` field, in-memory only). The file and its rows are never stored.
- Request:
- multipart/form-data with a `file` field (CSV or PDF, ≤ 4.5 MB)
- Response:
- 200 `{"ok":true,"file":{…}}` or `{"ok":false,"reason":"…","fileName":"…","message":"…"}`
- Errors:
- 400 unsupported format; 401 unauthenticated; 429 rate limited (hourly)
- GET
/subscriptionsSession cookieList the Consumer's active Subscriptions with their plan.
- Response:
- 200 `{"plan":"free"|"unlimited","subscriptions":[{…}]}`
- Errors:
- 401 unauthenticated
- POST
/subscriptionsSession cookieManually add one Subscription. Alerts always start off.
- Request:
- `{"merchant_name":"…","amount":9.99,"frequency":"monthly","next_renewal_date":"2026-03-01"}`
- Response:
- 201 `{"subscription":{…}}`
- Errors:
- 400 invalid body; 401 unauthenticated
- POST
/scan/saveSession cookieAtomically save confirmed Income, reviewed Subscriptions (Alerts off), and leftover Transactions. Never accepts Detection rows.
- Request:
- `{"idempotency_key":"…","income":{"action":"set","monthly_income":4800},"subscriptions":[{…}],"transactions":[{…}]}`
- Response:
- 201 `{"subscriptions":[{…}],"transactions":[{…}],"income_updated":true,"budget":{…}}`
- Errors:
- 400 invalid body or rows payload; 401 unauthenticated
- POST
/subscriptions/commitSession cookieSave every reviewed candidate after authentication. Never accepts Detection `rows`; new Subscriptions start with Alerts off.
- Request:
- `{"subscriptions":[{"merchant_name":"…","amount":9.99,"frequency":"monthly","next_renewal_date":"2026-03-01"}]}`
- Response:
- 201 `{"subscriptions":[{…}]}`
- Errors:
- 400 invalid body or `rows` field; 401 unauthenticated
- POST
/subscriptions/cancellation-planSession cookieGenerate an ephemeral Cancellation plan (with a support script) for one saved Subscription. The plan is returned only and never persisted.
- Request:
- `{"subscription_id":"<uuid>"}`
- Response:
- 200 `{"plan":{…}}`
- Errors:
- 400 invalid body; 401 unauthenticated; 404 subscription not found; 429 rate limited (hourly)
- GET
/budget/recommendationsSession cookieReturn ephemeral AI-powered Budget recommendations for the selected horizon. Each item is an in-product next step plus evidence from Remaining, Category limits, and Subscriptions. Recommendations are never persisted.
- Response:
- 200 `{"recommendations":[{"kind":"remaining","text":"Look at leftover spending — Remaining is $120.00 this month.","evidence":"Remaining is $120.00 this month after Subscriptions and other spending."}]}`
- Errors:
- 400 invalid query; 401 unauthenticated; 402 quarter/year or prior-month on free
- PATCH
/budget/categories/:idSession cookieRename a Category or change its optional monthly limit.
- Request:
- `{"name":"Groceries","monthly_limit":400}` (partial; `monthly_limit` may be null)
- Response:
- 200 `{"category":{…}}`
- Errors:
- 400 invalid id or body; 401 unauthenticated; 404 not found
- DELETE
/budget/categories/:idSession cookieDelete a Category. Transactions that used it keep their amounts and have no Category.
- Response:
- 200 `{"ok":true}`
- Errors:
- 400 invalid id; 401 unauthenticated; 404 not found
- POST
/budget/transactions/:id/as-subscriptionSession cookieIdentify a leftover outflow Transaction as a Subscription. Creates the Subscription with Alerts off and deletes the Transaction so Remaining does not count it twice.
- Request:
- `{"merchant_name":"Netflix","frequency":"monthly","next_renewal_date":"2026-09-16"}` (`merchant_name` optional; defaults to the Transaction description)
- Response:
- 200 `{"budget":{…},"subscription":{…}}`
- Errors:
- 400 invalid id, body, or inflow; 401 unauthenticated; 404 not found; 409 already counted in Subscriptions
- PATCH
/subscriptions/:idSession cookieUpdate amount, frequency, next renewal date, alert toggle, or soft-delete (`is_active`). Enforces the free monitored cap when enabling Alerts.
- Request:
- `{"alert_enabled":true}` (partial)
- Response:
- 200 `{"subscription":{…}}`
- Errors:
- 400 invalid id or body; 401 unauthenticated; 402 free monitored cap exceeded; 404 not found
- PATCH
/settingsSession cookieUpdate the Consumer's Alert channel settings (email/SMS toggles, phone).
- Request:
- `{"alert_email_enabled":true,"alert_sms_enabled":false,"alert_phone":null}`
- Response:
- 200 updated settings
- Errors:
- 400 invalid body; 401 unauthenticated
- POST
/billing/checkoutSession cookieCreate a Stripe Checkout Session for the recurring Premium plan ($7/month).
- Response:
- 200 `{"url":"https://checkout.stripe.com/…"}`
- Errors:
- 400 invalid body; 401 unauthenticated; 500 Stripe misconfigured
- POST
/webhooks/stripeShared secretStripe webhook: grant Premium while a paid subscription is in good standing, revert to free when it lapses. Signature-verified.
- Request:
- Stripe event payload with a `Stripe-Signature` header
- Response:
- 200 acknowledged
- Errors:
- 400 missing/invalid signature
- GET
/cron/alertsShared secretDaily Alert worker (Vercel Cron at 00:00 UTC). Sends email Alerts for renewals 3 days out.
- Request:
- Authorization: `Bearer <CRON_SECRET>`
- Response:
- 200 `{…}` alert run summary
- Errors:
- 401 missing/invalid secret
Notes
- Statements and Detection
rowsare never persisted — the API extracts in memory and returns candidates only. - New Subscriptions always start with Alerts off; the free plan monitors one renewal, Premium ($7/month) is unlimited.
- Alerts are email-only, sent three days before the renewal; the daily worker runs at 00:00 UTC.