Skip to content

Credits: reprice POST /api/research/web 5→1 credit + self-serve auto top-up opt-out #1861

Description

@sweetmantech

Tracking issue for two credit-billing changes motivated by the 2026-07-08 auto-recharge incident: a long-tenured free-tier customer's card was charged $5.46 off-session (refunded, re_3TqUw700JObOnOb51S1XHMKD) after platform-side changes repriced his year-old scheduled task; $5.70 of his $12.51 14-day spend was flat 5-credit research fees. Business context (out of scope here): reduce surprise-charge churn; the incident write-up lives in Attio on the customer's record.

Status: DONE 2026-07-09 — all three PRs merged (docs → api → chat) and live on prod; both goals verified end-to-end including a real-card charge sequence.

2026-07-08 decision (Sweets): the opt-out flag lives in Stripe customer metadata, not a database column — Stripe is the source of truth for charges and payment details, so consent sits next to the card it governs. Supersedes the credits_usage column suggestion below (kept struck-through for the record). Key consequence: card-on-file and charge-consent are decoupled — an opted-out customer can still top up manually via Checkout (which saves their card), and opting back in later is a metadata flip, no card re-entry.

Goal

  1. POST /api/research/web deducts 1 credit per call instead of 5. We pay Perplexity Search API $0.005/request ($5 per 1,000, flat, no token costs) — the current 5-credit fee is a 10x markup that punishes exactly the high-frequency agentic search we want. All other research-family pricing is unchanged (Songstats-backed endpoints stay at 5 — they cost ~$0.09/call amortized on the Professional plan and are already margin-negative; deep stays 25; enrich/extract unchanged).
  2. Customers can opt out of automatic top-up from the chat settings UI. Today docs/credits.mdx L134 says per-account control "is not yet exposed — reach out to support". Opted-out accounts fail closed to the existing explicit-consent path (402 + checkoutUrl), never a silent off-session charge.

PRs (updated 2026-07-09)

PR Item Base State
docs#270 credits.mdx: web-search price 5→1 + auto top-up opt-out contract (OpenAPI + 2 reference pages + nav) main ✅ merged 2026-07-09 (300fc0a) — local render verified 11/11 checks (results)
api#769 /research/web gate + deduction → 1 credit; Stripe-metadata opt-out in autoRechargeOrFail (fresh retrieve); GET/PATCH /api/accounts/{id}/auto-recharge main ✅ merged 2026-07-09 (c922691) — fully preview-verified incl. live auto-recharge sequence with a real card (endpoint checks, charge sequence); GET read-after-first-write lag (~2.5 min, card-less accounts only) flagged for chat#1862
chat#1862 Automatic top-up switch in a Billing section of the Account modal (live Stripe-read state + $5/500-credit helper text + error toast; redesigned from the dropdown checkbox per review 2026-07-09) main ✅ merged 2026-07-09 (3dccdae) — preview-verified end-to-end on default config (first pass, redesign + screenshots); prod deployment success

Merge sequencing: docs → api → chat. The docs change is the pricing/opt-out contract the rest fulfills; the chat toggle calls the new api endpoint. database migration dropped 2026-07-08 — no schema change needed with the Stripe-metadata design. All three merged 2026-07-09; both goals live on prod.

Done

  • Reprice POST /api/research/web from 5 credits to 1 (docs#270 + api#769).
    ✅ Shipped 2026-07-09 (both merged to main: docs 300fc0a, api c922691).
    Docs cost-table row 5 → 1; api gives /research/web its own gate (ensureWebResearchCredits, 1 credit) and postResearchWebHandler deducts 1 — the shared RESEARCH_CREDIT_COST is untouched, so the Songstats-backed family still gates and deducts 5.
    Verified live on the PR-head preview 2026-07-09: request succeeds at exactly remaining_credits = 1 and writes usage_events.credits_deducted_cents = 1 (16:26:06 UTC row); 402 at 0 balance carries required_credits: 1; sibling still deducts exactly 5 (332 → 327) — the sibling exercised was GET /api/research?q=… (search) rather than the criterion's GET /api/research/metrics (metrics needs a rostered artist fixture); both ride the same shared 5-credit handleResearch path (endpoint checks, charge sequence).

  • Self-serve opt-out of automatic top-up (chat settings UI + api + flag) (docs#270 + api#769 + chat#1862).
    ✅ Shipped 2026-07-09 (all merged to main in contract order: docs 300fc0a → api c922691 → chat 3dccdae; chat prod deployment success).
    Flag = Stripe customer metadata key auto_recharge_opt_out (presence = opted out; re-enabling deletes the key; resolved via resolveStripeCustomerForAccount, never email). autoRechargeOrFail does a fresh customers.retrieve check before any off-session charge and falls closed to the existing 402 + checkoutUrl. Surface: GET/PATCH /api/accounts/{id}/auto-recharge + an Automatic top-up switch in a Billing section of the chat Account modal — redesigned 2026-07-09 from the original dropdown checkbox (Sweets: a billing-consent control needs explanation a menu item can't carry) with $5/500-credit helper text and an error toast. database migration dropped 2026-07-08 (Stripe-metadata decision, see callout).
    Verified live 2026-07-09, including a real card: enabled + card + 0 balance → silent $5.34 off-session top-up fired and the request succeeded (balance 0 → 499); opted-out + chargeable card + 0 balance → 402 + checkoutUrl, no PaymentIntent, balance stayed 0; re-enable needed no card re-entry (charge sequence, endpoint matrix). Chat UI round-trip + reload persistence verified on the PR preview against the synced test-recoup-api (screenshots).
    Known accepted wrinkle: the GET reads the customer via Stripe's eventually-consistent Search index, so the first-ever opt-out on a card-less account can read back true for ~1–2.5 min across a reload (safety unaffected — the charge gate reads fresh; the UI seeds its cache from the PATCH response so it doesn't bounce in-session).

    • Done when: with the toggle off and remaining_credits below a request's cost, a billed request produces a 402 with checkoutUrl and no off-session PaymentIntent with metadata.purpose = credits_auto_recharge is created (manual Checkout PIs remain possible and desired — that's the escape hatch); completing that Checkout saves the card yet subsequent low-balance requests still 402 while the flag is present; toggling back on (key deleted) restores today's silent top-up without re-entering card details; the toggle reflects Stripe state across reloads and a toggle-off is honored by the very next request (fresh-read rule).

Architecture decisions

  • Per-tool pricing, not a flat research fee. The 5-credit default conflates two cost bases: Perplexity web search ($0.005/call, flat) and Songstats-backed catalog research (~$0.09/call amortized). Web search is repriced alone; touching the shared RESEARCH_CREDIT_COST would silently reprice the negative-margin family too.
  • Opt-out fails closed to explicit consent, not to silent failure. Opted-out + insufficient balance reuses the existing 402 + checkoutUrl contract (credits.mdx L161) rather than inventing a new error shape — clients already handle it.
  • Consent lives in Stripe customer metadata (decided 2026-07-08, supersedes the DB-column plan). Stripe is the source of truth for charges and payment details; the flag sits next to the card it governs, support can flip it from the Stripe dashboard with no deploy, and customer.updated events give an audit trail. Charge path must read it fresh (customers.retrieve), not from the eventually-consistent search index.
  • Default stays opt-in (enabled). Existing behavior is unchanged for accounts that don't touch the toggle; this is a kill switch, not a default flip.

Accepted regressions / tradeoffs

  • Consent doesn't port off Stripe. The x402/crypto payment path doesn't ride Stripe; if top-ups ever go multi-processor, the flag needs a per-processor home or a migration to our DB. Fine today — auto top-up is Stripe-only.
  • A deleted/recreated Stripe customer silently resets to opted-in (metadata doesn't survive recreation). Rare; mitigated by the toggle always rendering live Stripe state, so the UI never lies about current consent.

Source references

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions