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:
| Status | Code | Meaning |
|---|---|---|
| 400 | BAD_JSON | Body wasn't valid JSON. |
| 400 | VALIDATION | Body failed Zod validation. `fields` lists which fields failed. |
| 400 | MALFORMED | AI output didn't match schema (rare; auto-retry sometimes helps). |
| 401 | UNAUTHENTICATED | Missing / expired / revoked credential. |
| 403 | NO_CHANNELS_CONNECTED | Vibe Check / Predict SEO require a connected YouTube channel. |
| 404 | VIDEO_NOT_FOUND | Video private, deleted, or invalid id. |
| 404 | CHANNEL_NOT_FOUND | The channel id isn't one of your connected channels. |
| 404 | NO_TRANSCRIPT | No usable captions on this video. |
| 405 | METHOD_NOT_ALLOWED | Endpoint expects a different HTTP method. |
| 429 | LIMIT_REACHED | Plan quota exhausted for this metric (`usage` field shows where). |
| 429 | QUOTA_EXCEEDED | Daily AI-call budget spent — or, with `lifetimeExhausted: true`, the free plan's one-time allowance (it doesn't reset). |
| 429 | RATE_LIMITED | Upstream provider (e.g. Gemini) returned 429. Retry with backoff. |
| 502 | UPSTREAM_ERROR | Gemini / YouTube Data API call failed for a non-transient reason. |
| 503 | UNCONFIGURED | Server doesn't have an AI provider configured. |
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
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
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
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
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/hookMoments 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-momentsThe 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
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
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-kitToday'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
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, ... }
}
}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 }
}
}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
}
}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 }
}
}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
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 }
}
}"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." }] } }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."] } } }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 } }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 }] } }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.
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"
}
}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..."
}
}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..."
}
}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.
| Metric | Free | Creator | Pro |
|---|---|---|---|
| AI calls (all AI features)/ day | 15 | 100 | 400 |
| AI calls, one-time allowance/ lifetime | — | — | — |
| Vibe Check/ day | 1 | 10 | 40 |
| Predict SEO/ day | 1 | 15 | 60 |
| Earnings est./ day | 1 | 10 | 40 |
| AI ideas (Brief)/ month | 5 | 200 | 1,000 |
| Auto-replies/ month | 10 | 500 | 2,000 |
| Connected channels | 1 | 2 | 5 |
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 ◆