API Reference

API documentation

All public endpoints. JSON in / JSON out. Auth via long-lived API keys issued from your Settings page.

§Authentication

Every endpoint requires a bearer token. Issue one from Settings → API keys → Create new key. The plaintext token (vfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) is shown once at issuance — store it somewhere safe, you can never see it again.

Authorization: Bearer vfk_<your-key-here>

Same endpoints accept a session cookie (vf_session=...) when called from a logged-in browser; API keys are the recommended path for headless / mobile / 3rd-party use because they don't expire and aren't tied to a UA fingerprint.

Lost a key? Revoke it from Settings — auth lookups against revoked keys return 401 UNAUTHENTICATED.

§Error envelope

Every error response shares one shape:

{
  "error": {
    "code": "VALIDATION",
    "message": "Invalid input",
    "fields": { ... }   // optional, present on 400 VALIDATION
  }
}

Common codes:

StatusCodeMeaning
400BAD_JSONBody wasn't valid JSON.
400VALIDATIONBody failed Zod validation. `fields` lists which fields failed.
400MALFORMEDAI output didn't match schema (rare; auto-retry sometimes helps).
401UNAUTHENTICATEDMissing / expired / revoked credential.
403NO_CHANNELS_CONNECTEDVibe Check / Predict SEO require a connected YouTube channel.
404VIDEO_NOT_FOUNDVideo private, deleted, or invalid id.
404CHANNEL_NOT_FOUNDThe channel id isn't one of your connected channels.
404NO_TRANSCRIPTNo usable captions on this video.
405METHOD_NOT_ALLOWEDEndpoint expects a different HTTP method.
429LIMIT_REACHEDPlan quota exhausted for this metric (`usage` field shows where).
429QUOTA_EXCEEDEDDaily AI-call budget spent — or, with `lifetimeExhausted: true`, the free plan's one-time allowance (it doesn't reset).
429RATE_LIMITEDUpstream provider (e.g. Gemini) returned 429. Retry with backoff.
502UPSTREAM_ERRORGemini / YouTube Data API call failed for a non-transient reason.
503UNCONFIGUREDServer doesn't have an AI provider configured.
§GET/api/v1/health· public

Liveness probe for the web container: pings Postgres and Redis. No auth. (The background worker is a separate process — this does not cover it.)

// response

{
  "data": {
    "ok": true,
    "version": "0.1.0",
    "time": "2026-09-15T09:31:32.024Z",
    "uptimeSec": 4,
    "checks": { "database": true, "redis": true }
  }
}

// example

curl https://your.viewfinch/api/v1/health
§GET/api/v1/me· auth required

Returns the user attached to the current credential. `plan` is the billed plan; `effectivePlan` is the one limits are enforced at (they differ for admin accounts). `features.paidPlans` is false while paid plans aren't on sale yet.

// response

{
  "data": {
    "id": "uuid",
    "email": "you@example.com",
    "displayName": "Your Name",
    "avatarUrl": null,
    "plan": "creator",
    "effectivePlan": "creator",
    "emailVerifiedAt": "2026-05-10T08:00:00.000Z",
    "createdAt": "2026-05-10T07:58:00.000Z",
    "features": { "paidPlans": false }
  }
}

// example

curl -H "Authorization: Bearer vfk_..." https://your.viewfinch/api/v1/me
§GET/api/v1/onboarding· auth required

First-run progress — the same steps the dashboard, the mobile app and the extension show. Every step is derived from your account (a connected channel, an insight actually shown, a signed-in extension or app), never ticked by hand. Steps whose app is not in a store yet come back with available: false and are left out of the count. latestVideo is the upload the first insight is about: your newest video with synced comments, else your newest.

// response

{
  "data": {
    "steps": [
      { "id": "connect_channel", "done": true, "available": true },
      { "id": "first_insight", "done": false, "available": true },
      { "id": "extension", "done": false, "available": false },
      { "id": "mobile", "done": false, "available": false }
    ],
    "completed": 1,
    "total": 2,
    "complete": false,
    "syncing": false,
    "noVideos": false,
    "latestVideo": {
      "youtubeVideoId": "dQw4w9WgXcQ",
      "title": "How I plan a week of meals on ₹1,500",
      "publishedAt": "2026-09-20T10:00:00.000Z",
      "channelTitle": "Your Channel",
      "syncedComments": 42
    },
    "links": { "extension": null, "ios": null, "android": null }
  }
}

// example

curl -H "Authorization: Bearer vfk_..." https://your.viewfinch/api/v1/onboarding
§GET/api/v1/referrals· auth required

Your personal invite link and what it produced: members who signed up through it, how many connected a channel, and how many are on a paid plan. The code is issued on the first call and never changes.

// response

{
  "data": {
    "code": "k7m2qp9x",
    "link": "https://your.viewfinch/?ref=k7m2qp9x",
    "signups": 3,
    "connected": 2,
    "paid": 1
  }
}

// example

curl -H "Authorization: Bearer vfk_..." https://your.viewfinch/api/v1/referrals
§POST/api/v1/video/:youtubeVideoId/hook· auth required

Hook-vs-Promise Gap — whether the first 30 seconds deliver what the title sold, and how long the viewer waits to hear it. Costs no AI call and no Analytics query, so it works on a brand-new upload with no traffic. Pass timestamped segments, or omit them and stored transcript chunks are used.

// response

{
  "data": {
    "report": {
      "promiseTerms": ["easy", "pasta", "recipe"],
      "matchedTerms": [],
      "neverSaidTerms": [],
      "coverage": 0,
      "timeToFirstPromiseTermSec": 120,
      "verdict": "missing",
      "headline": "The first 30s are about something else — the title's subject does not come up until 2:00.",
      "hasTranscript": true
    },
    "videoTitle": "Easy Pasta Recipe",
    "hookWindowSec": 30
  }
}

// example

curl -X POST -H "Authorization: Bearer vfk_..." -H "content-type: application/json" \
  -d '{"segments":[{"start":0,"text":"hey everyone"},{"start":120,"text":"easy pasta recipe"}]}' \
  https://your.viewfinch/api/v1/video/dQw4w9WgXcQ/hook
§POST/api/v1/video/:youtubeVideoId/comment-moments· auth required

Moments your audience cites in the comments ('skip to 4:20'), clustered and ranked. The cheapest endpoint here — no AI call, no Analytics query, no YouTube request — so it keeps working when the AI budget is exhausted. Scores and ratios like '3:2' are rejected, and a moment needs at least two distinct commenters.

// response

{
  "data": {
    "moments": [
      {
        "atSec": 262,
        "mentions": 3,
        "totalLikes": 63,
        "score": 4.79,
        "said": "and then the whole thing collapsed",
        "topComment": "4:22 had me crying"
      }
    ],
    "headline": "Your audience keeps pointing at 4:22 (43% in) — 3 commenters referenced it.",
    "scanned": 812,
    "durationSec": 600
  }
}

// example

curl -X POST -H "Authorization: Bearer vfk_..." -H "content-type: application/json" -d '{}' \
  https://your.viewfinch/api/v1/video/dQw4w9WgXcQ/comment-moments
§GET/api/v1/channel/:channelId/retention-rules· auth required

The Rule — drop-offs that keep happening in the same place across your last 12 videos, clustered on position through the video rather than wall-clock seconds so long and short uploads compare. A pattern needs at least 3 videos and 25% of those examined. Costs up to 12 Analytics queries, so the result is cached for six hours; ?force=1 re-runs it. :channelId is the internal uuid.

// response

{
  "data": {
    "rules": [
      {
        "kind": "mid_roll_valley",
        "atRatio": 0.45,
        "typicalAtSec": 268,
        "videoCount": 7,
        "share": 0.58,
        "medianLostPct": 12.4,
        "recurringTerms": ["sponsor", "code"],
        "videoIds": ["..."],
        "headline": "7 of your last 12 videos lose viewers around 45% in (typically 4:28) — a median of 12.4% each time."
      }
    ],
    "headline": "Your most consistent habit costs about 12.4% of the audience and shows up in 7 of 12 videos.",
    "examined": 12,
    "skipped": 0,
    "cached": false
  }
}

// example

curl -H "Authorization: Bearer vfk_..." \
  https://your.viewfinch/api/v1/channel/2b7c.../retention-rules
§GET/api/v1/media-kit· auth required

Every connected channel with its live Sponsor Kit (or null). The kit's public page recomputes channel numbers from synced data each time it's opened, so the link never goes stale.

// response

{
  "data": {
    "kits": [
      {
        "channel": { "id": "uuid", "title": "My Channel", "oauthInvalidSince": null },
        "kit": {
          "slug": "Ab3dEf7h",
          "url": "https://your.viewfinch/kit/Ab3dEf7h",
          "headline": "Weekly recipes for busy families",
          "audienceNote": "Mostly parents, 25–34.",
          "rates": [{ "label": "Integration (60s)", "price": "₹8,000" }],
          "contactEmail": "collabs@example.com",
          "viewCount": 12,
          "createdAt": "2026-09-15T10:00:00.000Z",
          "updatedAt": "2026-09-15T10:05:00.000Z"
        }
      }
    ]
  }
}

// example

curl -H "Authorization: Bearer vfk_..." https://your.viewfinch/api/v1/media-kit
§POST/api/v1/media-kit· auth required

Create the channel's kit, or update the live one in place — the slug (and any link a sponsor already has) stays the same. All fields but channelId are optional; up to 6 rates. Returns 201 on create, 200 on update. Revoke with DELETE /api/v1/media-kit?channelId=… (the public page then 404s; a new kit gets a new slug).

// request body

{
  "channelId": "uuid",
  "headline": "Weekly recipes for busy families",
  "audienceNote": "Mostly parents, 25–34.",
  "rates": [{ "label": "Integration (60s)", "price": "₹8,000" }],
  "contactEmail": "collabs@example.com"
}

// response

{ "data": { "kit": { "slug": "Ab3dEf7h", "url": "https://your.viewfinch/kit/Ab3dEf7h", ... }, "created": true } }

// example

curl -X POST -H "Authorization: Bearer vfk_..." -H "Content-Type: application/json" \
  -d '{"channelId":"uuid","headline":"Weekly recipes"}' https://your.viewfinch/api/v1/media-kit
§GET/api/v1/extension/usage· auth required

Today's usage for the gated metrics (vibe check, predict SEO, earnings estimate), counted against the plan your limits are enforced at, plus the AI-call budget. `aiCalls.lifetime` is the free plan's one-time allowance (null on paid plans); `aiCalls` itself is null if the counter is briefly unavailable.

// response

{
  "data": {
    "date": "2026-09-15",
    "plan": "creator",
    "vibeCheck":      { "used": 1, "limit": 10, "nearLimit": false },
    "predictSeo":     { "used": 0, "limit": 15, "nearLimit": false },
    "revenueEstimate":{ "used": 0, "limit": 10, "nearLimit": false },
    "aiCalls":        { "used": 12, "limit": 100, "nearLimit": false, "lifetime": null }
  }
}

// example

curl -H "Authorization: Bearer vfk_..." https://your.viewfinch/api/v1/extension/usage
§POST/api/v1/extension/vibe-check· auth required

AI Vibe Check on an OWNED video (you must have its channel connected) OR metadata-only Watch Insights on any FOREIGN video. Discriminated by `kind` in the response.

// request body

{
  "videoId": "iG9CE55wbtY",         // 8-32 chars, [a-zA-Z0-9_-] only
  "videoTitle": "...",               // optional, ≤ 200 chars
  "topComments": ["..."],            // optional, ≤ 15 items, each ≤ 2KB
  "likeToViewRatio": 0.012,          // optional, 0-1
  "transcript": {                    // optional client-extracted DOM transcript
    "text": "...",                   // 20 chars - 100 KB
    "segments": [                    // optional, ≤ 20K segments
      { "start": 0.0, "text": "..." }
    ],
    "language": "en"                 // optional
  }
}

// response

// Owned-video path (Vibe Check fires)
{
  "data": {
    "kind": "vibe",
    "vibeCheck": {
      "hookTechnique": "...",
      "standoutQualityFactor": "...",
      "relatableFactor": "...",
      "actionableTakeaway": "..."
    },
    "modelUsed": "gemini-2.5-flash-lite",
    "transcriptSource": "cache" | "dom" | "api",
    "transcriptChars": 1234,
    "usage": { "used": 1, "limit": 10 },
    "insights": { /* same shape as foreign-path 'insights' below */ },
    "videoMeta": { "title": "...", "viewCount": 0, ... }
  }
}

// Foreign-video path (metadata-only Watch Insights)
{
  "data": {
    "kind": "insights",
    "insights": {
      "compositeScore": 64,
      "engagement": {
        "likeRatio": 1.23, "commentRatio": 0.05,
        "verdict": "average",
        "channelBaseline": { "source": "foreign_channel", "size": 20,
                             "likeRatioPercentile": 5,
                             "commentRatioPercentile": 15 },
        "composite": {
          "verdict": "below_avg",
          "likeLabel": "bottom 5% on likes",
          "commentLabel": "bottom 15% on comments",
          "summary": "..."
        }
      },
      "titleHook":   { "score": 40, "reasons": ["..."] },
      "revenue":     { "lowUsd": 25000, "highUsd": 124000, "summary": "..." },
      "velocity":    { "viewsPerDay": 12345, "trend": "rising", ... },
      "audienceSignal": { "sentiment": "mixed", ... },
      "topComments": [...],
      "audienceQuestions": [...],
      "tags": [...], "tagOpportunities": [...],
      "channelSummary": { ... },
      "vsYourChannel": { ... }
    },
    "videoMeta": { "title": "...", "viewCount": 24775139, ... }
  }
}
§POST/api/v1/extension/predict-seo· auth required

CTR-potential + search-ranking score for an OWNED video, plus title alternatives, suggested tags, and description advice. Burns one quota slot per call.

// request body

{
  "videoId": "8dyAfZybPIo"   // 8-32 chars, [a-zA-Z0-9_-] only
}

// response

{
  "data": {
    "predictSeo": {
      "ctrPotential":  { "score": 0-100, "reasoning": "..." },
      "searchRanking": { "score": 0-100, "reasoning": "..." },
      "titleAlternatives": ["...", ...],   // 2-5 items
      "suggestedTags":     ["...", ...],   // 3-12 items
      "descriptionAdvice": {
        "strengths":    ["..."],           // ≤ 3
        "improvements": ["..."]            // 1-4
      }
    },
    "modelUsed": "gemini-2.5-flash-lite",
    "videoMeta": {
      "title": "...", "channelTitle": "...",
      "viewCount": 0, "likeCount": 0, "currentTags": [...]
    },
    "usage": { "used": 1, "limit": 3 }
  }
}
§POST/api/v1/extension/score-batch· auth required

Cheap composite score (engagement + title + velocity + sentiment) for 1-100 videos at once. No AI call, no transcript fetch — pure Data API metadata. Used by the extension's thumbnail-score badges.

// request body

{
  "videoIds": ["aaaaaaaaaaa", "bbbbbbbbbbb"]   // 1-100 ids, each 8-32 chars
}

// response

{
  "data": {
    "scores": {
      "<videoId>": {
        "score": 0-100,
        "verdict": "excellent" | "good" | "average" | "below_avg",
        "trend":   "rising" | "stable" | "declining" | "fresh" | "unknown",
        "format":  "long" | "short",
        "channelTitle": "...",
        "viewCount": 0, "likeCount": 0, "commentCount": 0,
        "likeRatio": 1.23,           // percent
        "durationSec": 0,
        "titleHookScore": 0-100
      }
    },
    "notFound": ["<videoId>", ...]   // ids that didn't resolve
  }
}
§POST/api/v1/studio/brief· auth required

Generate a content brief for a topic, optionally personalised against a connected channel via RAG (transcript chunks + visual fingerprints from the user's prior videos).

// request body

{
  "topic": "How to nail your TED-style stage presence",   // 3-500 chars
  "channelId": "uuid"                                      // optional; enables RAG
}

// response

{
  "data": {
    "brief": {
      "title": "...",
      "hook": "...",
      "thumbnailConcept": "...",
      "outline": ["...", ...],     // 3-8 bullets
      "cta": "...",
      "tags": ["...", ...],         // 3-20
      "suggestedPublishWindow": "..."
    },
    "modelUsed": "gemini-2.5-flash-lite",
    "usedRag": true,
    "ragChunkCount": 5,
    "ragVisualCount": 3,
    "usage": { "used": 1, "limit": 10 }
  }
}
§GET/api/v1/coach· auth required

This week's coach — the one thing to do plus what happened (uploads, comment moments, the hook check, audience asks, replies waiting, deals due, fixes), built live from your synced data. Also: POST /api/v1/coach/test sends it to you now by every channel you switched on (3 per hour), and GET · POST /api/v1/me/preferences reads / sets coachEmail, coachPush, coachWhatsapp, uploadAlerts, whatsappNumber (E.164) and whatsappOptIn.

// response

{
  "data": {
    "digest": {
      "weekStart": "2026-09-28",
      "headline": "2 uploads this week — one ran ahead of your usual pace.",
      "action": { "kind": "hook", "text": "Say what the title promised in the first 15 seconds." },
      "items": [{ "kind": "moment", "title": "…", "body": "…" }]
    },
    "lastDelivery": { "weekStart": "2026-09-28", "delivery": { "email": "sent", "push": "skipped:no-device", "whatsapp": "skipped:off" } },
    "uploadChecks": []
  }
}

// example

curl -H "Authorization: Bearer vfk_..." https://your.viewfinch/api/v1/coach
§GET · POST/api/v1/deals· auth required

The deal desk. GET → { deals, summary (INR totals for this financial year: pipeline, invoiced-unpaid incl. GST, paid, TDS withheld), barter (Section 194R log per brand per FY) }. POST creates a deal: { brandName, amount (excl. GST), currency, barterValue, dueDate, deliverables, … }. One deal: GET · PATCH · DELETE /api/v1/deals/:dealId (PATCH → 409 INVOICE_LOCKED if it would change an issued invoice's money or billing details). POST /api/v1/deals/:dealId/invoice issues the next number in your series for the financial year (INV/26-27/001…); GET returns the invoice with its GST split. Your side of every invoice: GET · PUT /api/v1/invoice-profile. What to charge: GET /api/v1/rate-card?channelId=<uuid>[&market=IN|GLOBAL] — a starting range per deliverable from your median views (owner-only; never shown to sponsors). Bookkeeping help, not tax advice — check with your CA.

// request body

{ "brandName": "Boat", "amount": 40000, "dueDate": "2026-10-15", "deliverables": "1 integration (60–90s)" }

// response

{
  "data": {
    "deal": { "id": "…", "brandName": "Boat", "status": "pitched", "amount": 40000, "currency": "INR", "dueDate": "2026-10-15", "invoiceNumber": null }
  }
}
§GET · POST/api/v1/fixes· auth required

"Did it work?" — changes you committed to from a Drop-off dip ({ source: 'dropoff', youtubeVideoId, startRatio, endRatio }), a hook check ({ source: 'hook', youtubeVideoId }) or The Rule ({ source: 'rule', channelId, startRatio }), with fixText. Viewfinch re-measures the same spot on your next 2 uploads and compares with your uploads from before: status tracking | improved | no_change | worse. PATCH · DELETE /api/v1/fixes/:fixId edits or stops one; POST /api/v1/fixes/:fixId/check measures now (once per 10 minutes).

// response

{ "data": { "fixes": [{ "id": "…", "source": "hook", "status": "improved", "summary": "It worked: your openings reach the promise in 3s, down from 40s." }] } }
§GET · POST/api/v1/ab-tests· auth required

Hand title or thumbnail variants to YouTube Studio's own Test & compare, then record the winner YouTube picks. POST { youtubeVideoId, kind: 'title'|'thumbnail', variants: [2–3] } → the test + a Studio deep link. PATCH /api/v1/ab-tests/:testId { winnerIndex } records the winner; GET includes learnings — what tends to win on your channel once three title tests are decided.

// response

{ "data": { "tests": [], "learnings": { "decided": 3, "lines": ["Titles with a number won 3 of 3 tests where only some had one."] } } }
§GET · POST/api/v1/team· auth required

Pro. Invite a teammate by email as an editor (works in your account — replies, schedules, deals, fixes, reports) or a viewer (read-only); 5 seats. They accept at the emailed link with a verified account of that address, then switch into your workspace from their Agency page. Nobody on a team reaches billing, settings, API keys, the team itself or the extension link. PATCH · DELETE /api/v1/team/:memberId changes a role or removes someone at once. White-label client reports: GET · POST /api/v1/client-reports { channelId, brandName, brandColor, days: 7|30|90 } → a public /report/:slug with the numbers frozen; DELETE /api/v1/client-reports/:id revokes it.

// response

{ "data": { "member": { "invitedEmail": "manager@agency.com", "role": "editor", "status": "pending" }, "acceptUrl": "https://…/team/accept?token=…", "emailed": true } }
§GET/api/v1/instagram· auth required

Connected Instagram professional accounts (Meta's official Instagram Login) with the numbers the media kit shows: followers, average reel views, average likes / comments, engagement by followers. Connect with the browser redirect GET /api/auth/instagram/start. PATCH { id, includeInKit } shows or hides one on your kit; DELETE ?id= disconnects it. Read-only — Viewfinch never posts.

// response

{ "data": { "configured": true, "accounts": [{ "username": "creator_x", "followersCount": 12000, "stats": { "avgReelViews": 30000, "engagementRate": 5.2 }, "includeInKit": true, "needsReconnect": false }] } }
§POST/api/mcp· auth required

Viewfinch as an MCP server, for AI assistants (Model Context Protocol, Streamable HTTP, JSON responses). Authenticate with an API key holding the mcp scope. Ten tools: get_weekly_coach, list_recent_videos, get_comment_moments, check_hook, get_dropoff, get_audience_asks, list_deals, add_deal (the only one that changes anything), get_rate_card, list_fixes. Desktop apps that start local processes can use the stdio bridge in packages/mcp. No tool calls an AI model on Viewfinch's side.

// request body

{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_recent_videos", "arguments": { "limit": 3 } } }

// response

{ "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "{…}" }], "structuredContent": { "videos": [] }, "isError": false } }

// example

claude mcp add --transport http viewfinch https://your.viewfinch/api/mcp \
  --header "Authorization: Bearer vfk_..."

§Billing (Razorpay)

Subscriptions are powered by Razorpay Subscriptions. The /billing UI mounts Razorpay Checkout client-side; these endpoints are what it calls. Webhook events from Razorpay are the authoritative source for plan transitions.

All endpoints below return 503 RAZORPAY_NOT_CONFIGURED when the server is missing RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET.

§POST/api/v1/billing/subscribe· auth required

Create a Razorpay subscription for the given tier and currency. Returns the subscription_id and key_id needed to mount Razorpay Checkout. The user is NOT yet upgraded — the `subscription.activated` webhook does that.

// request body

{
  "tier": "creator" | "pro",
  "currency": "INR" | "USD"
}

// response

{
  "data": {
    "subscriptionId": "sub_xxx",
    "razorpayKeyId": "rzp_test_xxx",
    "currency": "INR",
    "tier": "creator",
    "amount": 500,
    "status": "created"
  }
}
§POST/api/v1/billing/verify· auth required

Verifies the Razorpay Checkout signature client-side after payment so the UI can show 'upgrading' feedback immediately. Does NOT change user.plan — that's the webhook's job. Returns 400 INVALID_SIGNATURE if the HMAC doesn't match.

// request body

{
  "razorpay_payment_id": "pay_xxx",
  "razorpay_subscription_id": "sub_xxx",
  "razorpay_signature": "..."
}

// response

{
  "data": {
    "verified": true,
    "message": "Payment signature verified..."
  }
}
§POST/api/v1/billing/cancel· auth required

Cancel the current active subscription. Default behavior is cancel-at-cycle-end (user keeps access until subscriptionPeriodEnd). Pass `{ immediate: true }` to cancel right away.

// request body

{
  "immediate": false                  // optional, default false
}

// response

{
  "data": {
    "subscriptionId": "sub_xxx",
    "status": "cancelled",
    "cancelAtPeriodEnd": true,
    "message": "Subscription set to cancel at period end..."
  }
}
§POST/api/webhooks/razorpay· public

Razorpay webhook endpoint. Verifies the x-razorpay-signature header (HMAC-SHA256 over raw body using RAZORPAY_WEBHOOK_SECRET); rejects with 401 on signature mismatch. Idempotent via INSERT … ON CONFLICT on (provider, event_id) so duplicate retries silently no-op. Handles: subscription.activated, .charged, .cancelled, .completed, .halted, .paused, .resumed, .updated, payment.failed.

// response

{ "data": { "ok": true } }
// or { "data": { "duplicate": true } } on retry
// or { "data": { "logged": true, "handled": false } } for unhandled events

§Rate limits & quotas

Plan quotas are enforced server-side per user (not per API key — issuing more keys doesn't grant more quota). Live counters available via /api/v1/extension/usage.

MetricFreeCreatorPro
AI calls (all AI features)/ day15100400
AI calls, one-time allowance/ lifetime———
Vibe Check/ day11040
Predict SEO/ day11560
Earnings est./ day11040
AI ideas (Brief)/ month52001,000
Auto-replies/ month105002,000
Connected channels125

Every AI-backed feature also spends from the AI-call budget, so a feature can be refused by either its own cap or that budget. The free plan's one-time allowance does not reset. 429 LIMIT_REACHED includes the live usage object; 429 QUOTA_EXCEEDED (optimize + thumbnail endpoints) carries used, limit, retryAfterSec and lifetimeExhausted — when that is true nothing resets, so ignore retryAfterSec.

◆ generated 2026-05-08 · Viewfinch v0.1 ◆