# UGC Copilot Public REST API — Reference (v1)

**Docs URL:** `https://www.ugccopilot.ai/docs/#/api-reference`
**API base URL:** `https://us-central1-viral-ugc-copilot.cloudfunctions.net`
**Status:** v1 — see [Not in v1](#not-in-v1) for known scope boundaries.
**Pricing:** see the [Pricing reference](#pricing-reference) section below.

The public API exposes 26 endpoints from the same backend that powers the UGC Copilot web app. API calls deduct from the unified credit pool — your subscription's monthly allocation plus any pre-paid credit packs. All endpoints accept and return JSON.

---

## Pay-as-you-go

**No subscription required.** Sign up for a free account, buy a credit pack, generate an API key, and start calling. Subscribers get a monthly credit allocation bundled in; non-subscribers spend exclusively from pre-paid packs. Subscriptions are billed monthly or annually (annual = 2 months free); the monthly credit allocation is identical on both intervals.

| You are… | How credits flow | Default limits |
|---|---|---|
| **Pay-as-you-go (no sub)** | Buy a pack via Stripe (200 / 500 / 2,500 / 10,000). Each call decrements your balance. Calls fail with `402 insufficient-credits` when the balance hits zero. | 2 keys, 3 concurrent ops/key, 1 AI Twin |
| **Creator subscriber** | 500 credits/month + any packs you've bought, drawn from a single pool. | 2 keys, 3 concurrent ops/key, 1 AI Twin |
| **Pro subscriber** | 1,800 credits/month + packs. | 5 keys, 8 concurrent ops/key, 5 AI Twins |
| **Business subscriber** | 4,000 credits/month + packs. | 10 keys, 25 concurrent ops/key, 20 AI Twins |

Per-credit pack rates (same for everyone):

| Pack | Price | Per-credit |
|---|---|---|
| 200 | $25 | $0.125 |
| 500 | $50 | $0.10 |
| 2,500 | $200 | $0.08 |
| 10,000 | $700 | $0.07 |

Subscribing wins on the credit math (creator $0.0580/credit, pro $0.0439/credit, business $0.0373/credit) plus UI features like project history and dedicated support.

---

## OpenAPI spec & SDKs

The full machine-readable API contract is published as **OpenAPI 3.1**:

- **Spec source:** [`docs/openapi.yaml`](./openapi.yaml)
- **Bundled JSON:** [`docs/openapi.bundled.json`](./openapi.bundled.json) — single-file, ready to feed to a code generator or LLM tool definition.

### TypeScript SDK

```bash
npm install @ugccopilot/sdk
```

```ts
import { UGCCopilotClient } from '@ugccopilot/sdk';

const client = new UGCCopilotClient({ apiKey: process.env.UGC_API_KEY! });

const video = await client.video.renderAndWait({
  visualPrompt: 'A creator unboxing a product, cinematic lighting',
  engine: 'seedance',
  modelName: 'bytedance/seedance-2.0/fast/image-to-video',
  duration: 8,
});
console.log(video.videoUrl);
```

The SDK handles bearer auth, auto-`Idempotency-Key` UUIDs, structured `UGCCopilotError`, retry-on-transient-failure with backoff, and `renderAndWait` polling helpers. See [`sdk/typescript/README.md`](../sdk/typescript/README.md) for the full API surface.

### Python SDK

```bash
pip install ugc-copilot
```

Generated from the OpenAPI spec via [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client) — both `.sync()` and `.asyncio()` variants of every endpoint. See [`sdk/python/README.md`](../sdk/python/README.md).

### Other languages / agent tool definitions

Run [`openapi-generator`](https://openapi-generator.tech/) against the bundled JSON for Go, Java, Rust, C#, Ruby, etc. For LLM tool-calling frameworks (Anthropic Agent SDK, OpenAI Functions, LangChain), most accept OpenAPI directly:

```python
# Anthropic Agent SDK
from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
    model="claude-opus-4-7",
    tools=[{"type": "openapi", "url": "https://www.ugccopilot.ai/docs/openapi.bundled.json"}],
    ...
)
```

---

## Quick start (curl)

If you'd rather skip the SDKs and use raw HTTP:

```bash
# 1. Sign up at https://www.ugccopilot.ai (no subscription required).
# 2. Buy a credit pack from Profile → Credits, OR subscribe to a tier.
# 3. Generate a key in the web UI (Profile → API Keys). Copy the raw key once.
# 4. Make your first call:
curl -X POST \
  https://us-central1-viral-ugc-copilot.cloudfunctions.net/proxyFetchTopSellingProducts \
  -H "Authorization: Bearer ugc_live_<your_key>" \
  -H "Content-Type: application/json" \
  -d '{"industry":"fitness"}'
```

Successful responses are raw JSON with no envelope. Errors follow the [error shape](#error-shape).

---

## Authentication

Every request must include:

```
Authorization: Bearer ugc_live_<32 hex chars>
```

API keys begin with `ugc_live_` followed by 32 hex characters (**41 chars total**). They are issued by the web UI under **Profile → API Keys** and shown exactly once at creation. Store them like passwords; we keep only a SHA-256 hash and the last 4 characters for display.

### Account requirements

Any non-disabled account may generate API keys. Per-call billing requires either an active subscription's monthly credits OR a positive pre-paid pack balance. Trial users may also create keys (their trial credits work for API calls). Calls return `402 insufficient-credits` when the credit pool is empty and `403 permission-denied` only if the account itself is disabled.

### CORS

API keys are rejected when sent from `ugccopilot.ai` or `www.ugccopilot.ai` browser origins (`403 permission-denied`). Browser-side code must use Firebase Authentication, not API keys. Server-side, mobile, and tooling use are the supported channels.

---

## Headers

### Request headers

| Header | Required | Notes |
|---|---|---|
| `Authorization: Bearer ugc_live_...` | Yes | API key auth |
| `Content-Type: application/json` | Yes (POST bodies) | All endpoints accept JSON |
| `Idempotency-Key` | Recommended for mutating + paid calls | UUID or any URL-safe string. Dedupes within a 24-hour window. See [Idempotency](#idempotency). |

### Response headers

Every API response (success or error) includes:

| Header | Value | Meaning |
|---|---|---|
| `Cache-Control` | `no-store` | Never cache API responses on client or proxy |
| `X-Request-ID` | UUID | Quote this when contacting support |
| `X-Credits-Remaining` | integer | Credits left in the current monthly window (excludes bonus credits below 0) |
| `X-RateLimit-Limit` | integer | Per-key bucket cap for this `actionType` |
| `X-RateLimit-Remaining` | integer | Calls remaining in the current 60-second window |
| `X-RateLimit-Reset` | unix seconds | When the window resets |
| `X-Idempotent-Replayed` | `true` | Set only when this response was served from the idempotency cache |
| `Retry-After` | seconds | Set on `429` responses (rate limit / concurrency / 5xx) |

---

## Error shape

All API-key error responses follow this exact shape:

```json
{
  "error": {
    "type": "validation",
    "code": "invalid-argument",
    "message": "Industry is required.",
    "request_id": "550e8400-e29b-41d4-a716-446655440000"
  }
}
```

| HTTP | `type` | `code` examples | When |
|---|---|---|---|
| `400` | `validation` | `invalid-argument`, `failed-precondition` | Malformed input, missing required fields, precondition violation |
| `401` | `authentication` | `unauthenticated` | Missing/malformed/revoked/expired key |
| `403` | `permission` | `permission-denied` | Account disabled, allow-list violation, CORS rejection, subscription required |
| `404` | `validation` | `not-found` | Resource doesn't exist or isn't yours |
| `409` | `idempotency` | `idempotency-in-progress` | Same key currently executing; retry with `Retry-After` |
| `409` | `internal` | `already-exists` | Resource already exists (e.g. duplicate Twin name) |
| `422` | `idempotency` | `idempotency-conflict` | Same key reused with a different payload |
| `429` | `rate_limit` | `resource-exhausted` | Per-key minute bucket exceeded |
| `429` | `concurrency` | `concurrency-exceeded` | Per-key in-flight cap exceeded; `Retry-After: 30` |
| `402` | `resource` | `insufficient-credits` | Credit pool exhausted (monthly allocation + pre-paid packs) — buy a pack to continue. Not retryable: unlike a `429`, waiting does not clear it |
| `500` | `internal` | `internal` | Unhandled server error; quote `request_id` to support |

Quote `request_id` in any support request — it's the only way for us to find the exact log line.

---

## Rate limits

Per-API-key, per-minute, per-`actionType` bucket. API counters are separate from web UI counters; using the API does not consume your web rate-limit budget and vice versa.

| `actionType` bucket | Max requests / min | Endpoints |
|---|---|---|
| `analysis` | 10 | Market research |
| `image_generation` | 40 | Image + scene image + twin image + social image |
| `script_generation` | 20 | Viral script, tone regen, parse own script |
| `video_generation` | 10 | Start/check/fetch video, stitch, overlays |
| `twin_crud` | 20 | Twin list/create/update/delete, voice analyze |
| `general` | 60 | Everything else |

When exceeded:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 47

{ "error": { "type": "rate_limit", "code": "resource-exhausted",
  "message": "API rate limit exceeded for image_generation. Max 40 requests per minute on this key. Retry in 47s.",
  "request_id": "..." } }
```

### Concurrency caps

Independent of rate limits. Caps the number of **simultaneously in-flight credit-deducting calls** per API key. Read-only endpoints (status polling, list endpoints) don't count.

When the cap is hit:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 30

{ "error": { "type": "concurrency", "code": "concurrency-exceeded",
  "message": "Concurrency cap reached: 3 in-flight credit-deducting operations on this API key. Retry in 30s or use a separate key.",
  "request_id": "..." } }
```

| Tier | In-flight credit-deducting calls per key |
|---|---|
| Trial | 3 (creator-tier fallback) |
| Creator | 3 |
| Pro | 8 |
| Business | 25 |
| PAYG (no subscription) | 3 (creator-tier fallback) |

Right pattern for batch fan-out: limit your client-side parallelism to your tier cap. Slot leaks (e.g. crashed worker mid-call) are reaped within 15 minutes by a server-side watchdog — you do not need to do anything.

---

## Idempotency

Send `Idempotency-Key: <uuid>` on any mutating call. The server caches the response (or in-progress lock) keyed on `(uid, endpoint, key)` and replays cached results on retry within a 24-hour TTL. UUIDs are recommended; any URL-safe string works.

**Cache hit** — same key, same endpoint, same payload as the original:
- Returns the original status code and body.
- Sets `X-Idempotent-Replayed: true`.
- Does **not** deduct credits, acquire a concurrency slot, or run the handler.
- Counts as one call against rate-limit budget for audit purposes.

**Conflict** — same key, same endpoint, **different payload**:
- Returns `422 idempotency-conflict`. Don't reuse keys across distinct logical requests.

**In-progress** — same key while the original is still executing:
- Returns `409 idempotency-in-progress` with `Retry-After: 5`.

**Errors** are cached only when deterministic and 4xx (`invalid-argument`, `not-found`, `permission-denied`, `unauthenticated`). Transient failures (rate limit, concurrency, insufficient credits, 5xx, internal) release the lock so retries run fresh.

**Body size cap:** responses larger than 800 KB are not cached. The lock is released and the next retry re-runs.

---

## Billing semantics — failures, timeouts, and refunds

Every credit-deducting endpoint uses a **reserve → refund** pattern: the cost is deducted when the call starts (closing the race where parallel calls could both pass a balance check), then automatically refunded if the call fails.

| Scenario | Charged? | What to do |
|---|---|---|
| Call succeeds | Yes — the documented cost | Nothing |
| Call fails server-side (4xx/5xx, safety filter, engine error) | **No** — auto-refunded before the error response | Retry if transient |
| Your client times out, backend **also** failed | No — auto-refunded | Retry |
| Your client times out, backend **completed** | **Yes** — the work was done | Retry with the **same `Idempotency-Key`**: the replay returns the completed result at no extra cost |
| Async video render fails after `proxyStartVideoGeneration` | No — refunded when the failure is observed | See the polling contract below; keep polling (or persist `operationName` and resume) so the refund lands |

**The client-timeout case is why `Idempotency-Key` matters on paid calls.** Generation endpoints run long — image generation commonly takes 30–70 s and the server budget is 300 s. If your HTTP client gives up at 60 s while the backend finishes at 70 s, you were charged for a result you never received. With an `Idempotency-Key`, that result is cached for 24 h: re-send the identical request and you get the finished output back free (`X-Idempotent-Replayed: true`). Without one, re-sending is a new call and a new charge. Set client timeouts ≥ 300 s and always send an `Idempotency-Key` on credit-deducting calls.

Refunds that occur after the response has been sent (async render failures) return credits to your pool but are not re-attributed to the original usage event — `proxyGetApiUsageEvents` reports the net cost observed while each call executed. Usage aggregates on the Profile page update hourly.

---

## Async video generation — the polling contract

Video is the most-used and easiest-to-misuse part of the API. The contract is:

1. `POST /proxyStartVideoGeneration` — deducts credits up-front and returns an `operation` reference. Use `Idempotency-Key`.
2. `POST /proxyCheckVideoStatus { operationName, engine }` — poll until `done: true`.
3. `POST /proxyFetchVideo { operationName, engine, isExtension? }` — download the finished video.

### Polling cadence

Replicate the cadence used by the web UI in `hooks/useVideoGenerator.ts`:

- **Standard renders:** first poll at 15s, back off ×1.2 up to 60s max, ~60 attempts (~15 min ceiling).
- **HQ renders:** first poll at 30s, back off ×1.2 up to 90s max, ~80 attempts (~40 min ceiling).
- **Give up after 3 consecutive poll failures.** Don't hammer.

### Critical gotchas

1. **Operation names are opaque, provider-specific strings.** Sora returns `projects/.../operations/...`, Veo returns its own format, Kling/Seedance return UUIDs. Always pass the `engine` field alongside — never parse the name yourself.
2. **Credits are auto-refunded on engine failure, but not on client abandonment.** Credits are deducted at `proxyStartVideoGeneration`. If `proxyCheckVideoStatus` returns `done: true` with an `error`, the server has already refunded inside the same call — no action needed on your side. (The same applies to a pre-render eval block — `apiCode: "PRE_RENDER_EVAL_BLOCKED"` refunds before the error returns.) But if your client dies before polling reveals the failure, the refund won't happen until something else polls that operation. `Idempotency-Key` prevents *duplicate* deductions on retry, not *unattended-job* refunds. Persist the `operationName` somewhere durable before disconnecting and resume polling on restart.
3. **Video extensions are retired.** They only ever existed on Sora (retired 2026-09-24); `editVideoId` is now ignored, so `isExtension` on `proxyFetchVideo` is no longer needed.
4. **HTTP body limit: ~32 MB.** This is the binding constraint on any endpoint that accepts inline base64 (audio or image data). `proxyAnalyzeVoice` accepts base64 audio inline — the practical max is ~32 MB body, ≈ 24 MB raw audio. `proxyAnalyzeReferenceVideo` sidesteps the limit by reading from Firebase Storage; for any reference video, upload to `projects/{uid}/reference-videos/<file>` first and pass `storagePath`. Signed-URL upload endpoints for arbitrary blob types are a v2 candidate.

### End-to-end recipe (JavaScript)

```javascript
const BASE = 'https://us-central1-viral-ugc-copilot.cloudfunctions.net';
const KEY = process.env.UGC_API_KEY;

const headers = (idem) => ({
  'Authorization': `Bearer ${KEY}`,
  'Content-Type': 'application/json',
  ...(idem ? { 'Idempotency-Key': idem } : {}),
});

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

async function call(endpoint, body, idem) {
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST', headers: headers(idem), body: JSON.stringify(body),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`${res.status} ${err?.error?.code || ''}: ${err?.error?.message || res.statusText}`);
  }
  return { data: await res.json(), headers: res.headers };
}

async function generateVideo({ visualPrompt, engine, modelName, duration }) {
  const idem = crypto.randomUUID();
  const { data: start } = await call('proxyStartVideoGeneration',
    { visualPrompt, engine, modelName, duration }, idem);
  const operationName = start.operation.name;

  // Standard polling cadence: 15s start, ×1.2 backoff, 60s cap, 60 attempts
  let delay = 15000;
  for (let attempt = 0; attempt < 60; attempt++) {
    await sleep(delay);
    delay = Math.min(60000, Math.floor(delay * 1.2));
    const { data: status } = await call('proxyCheckVideoStatus',
      { operationName, engine });
    if (status.done) {
      if (status.error) throw new Error(`Render failed: ${status.error.message}`);
      const { data: video } = await call('proxyFetchVideo',
        { operationName, engine, duration });
      return video.videoUrl;
    }
  }
  throw new Error('Render timed out after 15 min');
}
```

### End-to-end recipe (Python)

```python
import os, time, uuid, requests

BASE = 'https://us-central1-viral-ugc-copilot.cloudfunctions.net'
KEY = os.environ['UGC_API_KEY']

def headers(idem=None):
    h = {'Authorization': f'Bearer {KEY}', 'Content-Type': 'application/json'}
    if idem: h['Idempotency-Key'] = idem
    return h

def call(endpoint, body, idem=None):
    r = requests.post(f'{BASE}/{endpoint}', json=body, headers=headers(idem), timeout=300)
    if not r.ok:
        err = r.json().get('error', {}) if r.headers.get('content-type','').startswith('application/json') else {}
        raise RuntimeError(f"{r.status_code} {err.get('code','')}: {err.get('message', r.reason)}")
    return r.json(), r.headers

def generate_video(visual_prompt, engine, model_name, duration):
    idem = str(uuid.uuid4())
    start, _ = call('proxyStartVideoGeneration',
        {'visualPrompt': visual_prompt, 'engine': engine,
         'modelName': model_name, 'duration': duration}, idem)
    op = start['operation']['name']

    delay = 15
    for _ in range(60):
        time.sleep(delay)
        delay = min(60, int(delay * 1.2))
        status, _ = call('proxyCheckVideoStatus', {'operationName': op, 'engine': engine})
        if status['done']:
            if status.get('error'):
                raise RuntimeError(f"Render failed: {status['error']['message']}")
            video, _ = call('proxyFetchVideo',
                {'operationName': op, 'engine': engine, 'duration': duration})
            return video['videoUrl']
    raise TimeoutError('Render timed out after 15 min')
```

---

## Webhooks

For agent-style automation, polling burns request budget and adds latency. Register an HTTPS webhook endpoint and we'll POST a signed event to your URL when a video render completes (or fails). Combine **low-frequency polling (60s)** with webhooks for the best agent ergonomics — webhooks fire on the next status check after completion, so polling at 60s gives you ~60s of additional latency vs. polling at 15s but cuts your API call volume by 4×.

### Register an endpoint

Endpoints are managed in the web UI under **Profile → Webhooks**. Each user can have up to 5 active endpoints. URLs must be HTTPS and publicly reachable (internal/private addresses are rejected). The signing secret is returned **exactly once** at creation; treat it like a password.

### Event types (v1)

| Event | Fired when | `data` shape |
|---|---|---|
| `video.completed` | A video render finishes successfully | `{ operationName, engine, modelName, creditCost, videoUrl, qc?, fallbackWarning? }` — fires for **every** finished render, including QC-failed ones; the `qc` verdict and silent-fallback disclosure ride the payload (see `proxyCheckVideoStatus`). A web-client free retry produces a second operation with its own event. |
| `video.failed` | A render hits a safety/moderation/timeout/engine error | `{ operationName, engine, modelName, creditCost, error: { code, message } }` |

`videoUrl` is a temporary Firebase Storage signed URL with a typical 7-day expiry. To download permanently, copy the bytes to your own storage when the webhook arrives.

### Payload shape

```json
{
  "id": "op_proj_abc_def_success",
  "type": "video.completed",
  "apiVersion": "2026-04-29",
  "createdAt": "2026-04-29T18:24:11.044Z",
  "data": {
    "operationName": "projects/.../operations/...",
    "engine": "seedance",
    "modelName": "bytedance/seedance-2.0/fast/image-to-video",
    "creditCost": 36,
    "videoUrl": "https://storage.googleapis.com/..."
  }
}
```

`id` is **stable per real-world event** — if a customer poll AND the scheduled poller both detect completion (rare), they generate the same `id` and only one delivery occurs. Your handler should still be idempotent on `id` to be safe.

### Headers

| Header | Value |
|---|---|
| `User-Agent` | `UGCCopilot-Webhooks/1.0` |
| `Content-Type` | `application/json` |
| `X-Webhook-Event-Id` | `<delivery_id>` (unique per attempt) |
| `X-Webhook-Event-Type` | `video.completed` or `video.failed` |
| `X-Webhook-Attempt` | `1`–`6` |
| `X-Webhook-Signature` | `t=<unix_seconds>,v1=<hex_hmac_sha256>` |

### Verifying signatures

The signature header has the form `t=<timestamp>,v1=<signature>`. Compute `HMAC_SHA256(secret, "${timestamp}.${raw_body}")` and compare in constant time. Reject events where `|now - timestamp| > 5 min` to prevent replay.

```python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(','))
    t = int(parts['t'])
    sig = parts['v1']
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

```javascript
const crypto = require('crypto');

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
  const t = parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex'));
}
```

### Delivery semantics

- **At-least-once.** Your handler must be idempotent on the event `id`.
- **Retry schedule:** 1 min, 5 min, 30 min, 2 h, 6 h (5 retries, ~9 h total ceiling, 6 attempts max).
- **Retried on:** network errors, timeouts (>10 s), HTTP 408, 429, 5xx.
- **Not retried on:** other 4xx (client bug — fix and we'll redeliver via the next event).
- Endpoints disabled mid-retry stop receiving redeliveries.

### Recommended handler skeleton

```javascript
app.post('/webhooks/ugc-copilot', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(process.env.UGC_WEBHOOK_SECRET, req.headers['x-webhook-signature'], req.body)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // Dedupe — your storage of event.id MUST be the source of truth.
  if (alreadyProcessed(event.id)) return res.status(200).end();

  switch (event.type) {
    case 'video.completed':
      enqueueDownloadAndPost(event.data.operationName, event.data.videoUrl);
      break;
    case 'video.failed':
      logFailure(event.data);
      break;
  }
  markProcessed(event.id);
  return res.status(200).end();
});
```

Respond `2xx` within 10 seconds. Long-running work (downloads, post-processing) should be deferred to a queue.

---

## Endpoint reference

All endpoints are `POST` to `https://us-central1-viral-ugc-copilot.cloudfunctions.net/<endpointName>`.

### Market research

#### `proxyFetchTopSellingProducts`
Returns 5–8 trending products in an industry with brand, price, ad creative angle, and ideal influencer profile.

- **Bucket:** `analysis` · **Cost:** 1 credit (first call ever on the account is free — `hasUsedFreeAnalysis` flag)
- **Body:** `{ "industry": string }`
- **Returns:** `{ "topSellingProducts": [{ productName, brand, price, niche, successfulAdCreative, idealCustomer, idealInfluencer, imagePrompt }] }`

#### `proxyFetchFullMarketAnalysis`
Top 3 trends, viral content characteristics, customer personas, keywords, and viral hook templates for an industry.

- **Bucket:** `analysis` · **Cost:** free (no per-call deduction; subject to rate limit)
- **Body:** `{ "industry": string }`
- **Returns:** `{ trends: string[], characteristics: string[], personas: [{name,description}], topKeywords: string[], viralHooks: [{type,description}], strategyRecommendations: string[] }`

### Image generation

All image endpoints accept `quality: "standard" | "hq"` (default `"standard"`). HQ uses a more expensive model and costs 2 credits instead of 1.

#### `proxyGenerateImage`
General-purpose AI image generation.

- **Bucket:** `image_generation` · **Cost:** 1 credit (standard) / 2 credits (HQ); first image gen ever on the account is free (`hasUsedFreeImageGen` flag)
- **Body:** `{ prompt: string, aspectRatio?: "1:1"|"9:16"|"16:9", productImage?: { data: base64, mimeType }, imageType?: "person"|"product", quality?: "standard"|"hq", resolution?: string }`
- **Returns:** Firebase Storage signed URL (string, valid 24h)

#### `proxyGenerateIdealInfluencer`
Generates a structured persona description (text-only) — used as input to subsequent image generation calls.

- **Bucket:** `image_generation` · **Cost:** free (no per-call deduction; image generation is charged separately when you call `proxyGenerateImage`)
- **Body:** `{ genInput: { url?, images?, description? }, isFaceless?: boolean, influencerGender?, influencerAge?, influencerEthnicity?, influencerStyle?, userInfluencerDescription?: string (10–2000 chars, overrides the steering enums), twinDescription?: string, projectMode?, contentPreset?: { environment?, tone?, setting? }, analysisResult?, videoAnalysis? }`
- **Returns:** `{ productDescription, influencerDescription, imagePrompt, structuredCharacterAttributes, characterEmbedding }`

#### `proxyGenerateSceneImage`
Generates a single scene image with optional product + influencer composition.

- **Bucket:** `image_generation` · **Cost:** 1 credit (standard) / 2 credits (HQ)
- **Body:** `{ visualPrompt: string, productImage?, influencerImage?, productDescription?, interactionPrompt?, aspectRatio?: "1:1"|"9:16"|"16:9", quality?: "standard"|"hq", isFaceless?: boolean }`
- **Returns:** image URL

#### `proxyEditSceneImage`
Edits an existing scene image (no overlay text).

- **Bucket:** `image_generation` · **Cost:** 1 credit (standard) / 2 credits (HQ)
- **Body:** `{ sceneImage: { data: base64, mimeType }, editPrompt: string, quality?, aspectRatio? }`
- **Returns:** image URL

#### `proxyGenerateTwinImage`
Generates a 1:1 profile image for an AI Twin. Aspect ratio is locked to 1:1.

- **Bucket:** `image_generation` · **Cost:** 2 credits (always HQ for face consistency)
- **Body:** `{ twinId: string, editMode?: boolean, existingImageUrl?, editPrompt?, referenceImageUrls?: string[] (max 5), gender?, ageRange?, ethnicity?, twinDescription?, nicheTopics?, contentPillars?, voiceDescription?, quality? }`
- **Returns:** image URL

#### `proxyGenerateSocialImage`
Social-media-ready image with optional text overlay.

- **Bucket:** `image_generation` · **Cost:** 1 credit (standard) / 2 credits (HQ)
- **Body:** `{ twinDescription: string, scenario: string, aspectRatio?, referenceImageUrls?, ethnicity?, gender?, ageRange?, quality?, existingImageUrl?, editPrompt?, includeTextOverlay?: boolean, textOverlayContent?: string }`
- **Returns:** image URL

### Script generation

#### `proxyGenerateViralScript`
Generates a full UGC ad script with hooks, scenes, CTAs, and platform variations.

- **Bucket:** `script_generation` · **Cost:** 1 credit
- **Body:** `{ productDescription: string, projectMode: "product-ad"|"ugc-creator"|"podcast-style"|"influencer-noproduct"|"own-script"|"vlog"|"clone-video", industry?, productImages?, influencerDescription?, influencerImage?, engine?: "sora"|"veo"|"kling"|"seedance"|"omni", modelName?, isFaceless?, analysisResult?, videoAnalysis?, duration? }`
- **Returns:** `{ hooks: [{type,visual,script}], scenes: [{sceneNumber,script,visual,scriptType,duration,sound,interaction}], ctas: [{type,script}], backgroundMusic, platformVariations: { tiktok, instagram_reels, youtube_shorts }, performancePrediction: { estimatedEngagement, engagementScore, viralityRating }, isFaceless }`

#### `proxyRegenerateScriptWithTone`
Regenerates an existing script with a new emotional tone.

- **Bucket:** `script_generation` · **Cost:** 1 credit
- **Body:** `{ originalScript: <full script object>, newTone: string, projectMode? }`
- **Returns:** Same shape as `proxyGenerateViralScript` but without `performancePrediction`.

#### `proxyParseOwnScript`
Parses a user-written script into the structured format with auto-generated visuals, sounds, and platform variations.

- **Bucket:** `script_generation` · **Cost:** 1 credit
- **Body:** `{ rawScript: string (10–10000 chars), twinDescription?, productDescription?, isFaceless? }`
- **Returns:** Same shape as `proxyGenerateViralScript`.

### Video lifecycle

#### `proxyStartVideoGeneration`
Starts an asynchronous video render. **This is when credits are deducted.**

> **Sora 2 is retired** (OpenAI shut the Sora API down on 2026-09-24). `engine: "sora"` is still accepted but renders on Seedance: `sora-2-pro` maps to Seedance HQ, any other Sora model name to Seedance Fast, `editVideoId` (Sora extend) is ignored, and the response's `engine` field reports the engine that actually rendered. Pass `engine: "seedance"` directly; `sora` is removed from the enum after the shutdown.

- **Bucket:** `video_generation` · **Cost:** dynamic via `getVideoCreditCost(engine, quality, duration)` — engine-specific. See the [Pricing reference](#pricing-reference) below. Cheapest render is Seedance standard at 18 credits per 4s (36 for an 8s clip); most expensive per clip is Veo HQ at 130 credits (fixed regardless of duration) or a long Seedance 2.5 / Kling 4K take. Kling/Seedance/Omni scale linearly with duration; Veo is fixed-cost per render — **40 credits std / 130 credits HQ regardless of duration**.
- **Body:** `{ visualPrompt: string, engine: "sora"|"veo"|"kling"|"seedance"|"omni", modelName: string, sceneImage?: { data, mimeType }, duration?: number, editVideoId?: string, isFaceless?, projectMode?, productDescription?, influencerDescription?, useMotionControl?: boolean, klingMotionOrientation?: "video"|"image", referenceVideoUrl?: string, qcRetryOfOperation?: string }`
- **QC free retry (`qcRetryOfOperation`).** When a completed render's `proxyCheckVideoStatus` response carried `qc: { pass: false, retryAvailable: true }`, you may resubmit the **identical** generation parameters once with `qcRetryOfOperation` set to that operation's id (slashes replaced with underscores). The re-render costs **0 credits** (`creditCost: 0` in the response). Single-use per failed render; the `visualPrompt` must match the original exactly — a mismatch fails with `failed-precondition` / `apiCode: "QC_RETRY_PROMPT_MISMATCH"`, an invalid/reused/non-owned token with `apiCode: "QC_RETRY_UNAVAILABLE"`. The free redo re-rolls engine variance on the render you paid for; it is not a new render.
- **Pre-render eval.** Product-ad renders (and any Seedance render with a `sceneImage`) get a fast prompt check before engine submission. One case hard-blocks: a Seedance-bound image that appears to contain a human face (the engine would reject it and silently degrade to text-to-video). That block returns `failed-precondition` / `apiCode: "PRE_RENDER_EVAL_BLOCKED"` with a fix `suggestion`, and **the reserved credits are refunded before the error returns** — a blocked call costs nothing. Everything else is advisory: the 200 response may carry `promptWarnings: [{ code, reason, suggestion }]` (e.g. `GARBLED_TEXT_RISK` when the prompt demands long on-screen text that engines reliably garble).
- **Kling Motion Control (clone-video sub-mode).** Set `useMotionControl: true` with `engine: "kling"` to route to fal.ai's Kling v3 Motion Control endpoint. Requires `projectMode: "clone-video"` and a `referenceVideoUrl` served from `firebasestorage.googleapis.com` or `storage.googleapis.com`. Output is reference-driven: `klingMotionOrientation: "video"` caps each scene at 30s with body+camera motion; `"image"` caps at 10s for camera-led shots. **Pricing is separate** from standard Kling 3.0 image-to-video — `35` std / `70` Pro base, divisor `6.4`. `modelName` should be `fal-ai/kling-video/v3/standard/motion-control` or `fal-ai/kling-video/v3/pro/motion-control`. Reference video's audio is dropped (`keep_original_sound: false`); supply your own voice track at stitch time.
- **Returns:** `{ operation: { name: string, done: false, metadata: { contentLength, beginTime, endpoint } }, assembledPrompt: string|null }`
- **Idempotency-Key strongly recommended.** Without it, a network retry can cause double-deduction.

#### `proxyCheckVideoStatus`
Polls operation status. **No credits.**

- **Bucket:** `video_generation` · **Cost:** free
- **Body:** `{ operationName: string, engine: "sora"|"veo"|"kling"|"seedance"|"omni" }`
- **Returns (in-progress):** `{ done: false, progress?: 0–100 }` — `progress` is present on Sora/Kling/Seedance (estimated; not always linear), absent on Veo. Polling-rate-limit hits during long renders return `{ done: false }` with no `progress` and you should just retry on the next tick.
- **Returns (completed):** `{ done: true, response: { generatedVideos: [{ video: { uri: string } }] }, qc?, fallbackWarning? }`. Veo also includes a top-level `name` field; other engines do not.
  - `qc?: { checked, pass?, defects?, retryAvailable?, retried? }` — post-render quality verdict, present on product-ad renders and fallback-degraded renders (absent = not checked). A vision model judges the finished clip against the render prompt for garbled text, anatomy errors, wrong product, gross prompt mismatch, and severe artifacts. `pass: false` with `retryAvailable: true` grants **one free re-render** (see `qcRetryOfOperation` on `proxyStartVideoGeneration`); `retried: true` marks a clip whose free retry also failed the check — the final attempt is always delivered, never withheld. The same `qc` object rides the `video.completed` webhook payload.
  - `fallbackWarning?: string` — present when the engine silently re-rendered **without** your reference image (e.g. Seedance's face policy rejected it). The clip's product/subject may be invented from text — surface this to the user and double-check product accuracy.
- **Returns (failed):** `{ done: true, error: { message: string, code: string } }`. Codes you'll see:
  - `SAFETY_BLOCK` — Veo, Kling, Seedance moderation blocks
  - `MODERATION_BLOCKED` — Sora moderation block (different spelling — Sora-only)
  - `GENERATION_FAILED` — engine errored or completed without a video
- **Auto-refund on engine failure.** When this endpoint returns `error`, the server has already refunded the original `proxyStartVideoGeneration` credit deduction (and any bonus credits used) inside the same response. You don't need to do anything to recover the credits — but a defensive client should always check `done && error` before treating a render as paid-for.

#### `proxyFetchVideo`
Downloads the finished video and returns a Firebase Storage signed URL.

- **Bucket:** `video_generation` · **Cost:** free
- **Body:** `{ operationName: string, engine: string, duration?: number, isExtension?: boolean }`
- **Returns:** `{ videoUrl: string, mimeType: "video/mp4", isWatermarked: false }`

#### `proxyStitchVideos`
Concatenates 1–10 clips with optional cross-fade transitions (default ~1s, clamped 0.3–2.0s). Falls back to plain concat when clips are too short or ffmpeg's xfade filter rejects the input.

- **Bucket:** `video_generation` · **Cost:** free (per-clip render costs were already paid at `proxyStartVideoGeneration`)
- **Body:** `{ videoUrls: string[] (1–10), engines?: ('sora'|'veo'|'kling'|'seedance'|'omni')[], useCrossfade?: boolean (default true), crossfadeDuration?: number (0.3–2.0s) }`
- **Returns:** `{ videoUrl: string, mimeType: "video/mp4", finalUrl?: string }` — `finalUrl` is a deprecated alias for `videoUrl` returned for backward compatibility.

#### `proxyApplyTextOverlay`
Burns 1–5 text overlays into a video using FFmpeg.

- **Bucket:** `video_generation` · **Cost:** 1 credit (text_overlay)
- **Body:** `{ videoUri: string, overlays: [{ text, position: "top"|"center"|"bottom", fontSize, fontColor, backgroundColor?, opacity, duration?, startTime? }] (1–5), aspectRatio?: "9:16"|"16:9"|"1:1" }`
- **Returns:** `{ overlayVideoUrl: string }`

#### `proxyGenerateOverlaySuggestions`
Returns up to 5 AI-suggested overlay copies for a given scene/script.

- **Bucket:** `general` · **Cost:** 1 credit
- **Body:** `{ productDescription?, sceneVisual?, scriptText?, platform?: "tiktok"|"instagram"|"youtube" }` (at least one of the description fields required)
- **Returns:** `{ suggestions: [{ id, text (≤50 chars), position, style: "bold"|"subtle"|"outline", category: "cta"|"hook"|"social_proof"|"urgency" }] }`

#### `saveVideoToLibrary`
Copies a generated video from temporary to permanent storage.

- **Bucket:** `general` · **Cost:** free (counts against tier's saved-video quota)
- **Body:** `{ sourceUrl: string, name?, visualPrompt?, productDescription?, influencerDescription?, hooks?, sceneTone?, engine?, modelName?, projectMode?, industry?, duration?, operationId?, structuredCharacterAttributes?, characterEmbedding? }`
- **Returns:** `{ savedUrl: string, savedAt: ISO8601 }`

#### `proxyGetVideoLibrary`
Lists saved videos with cursor pagination.

- **Bucket:** `general` · **Cost:** free
- **Body:** `{ pageSize?: number (1–50, default 20), startAfterId?: string }`
- **Returns:** `{ videos: [...], nextPageCursor: string|null, hasMore: boolean }`

### AI Twin management

A "Twin" is a reusable creator identity (face, voice, content style) shared across projects. Limits per tier: trial 1, creator 1, pro 5, business 20.

#### `proxyListTwins`
- **Bucket:** `twin_crud` · **Cost:** free
- **Body:** `{}`
- **Returns:** array of Twin objects (no pagination — hard cap 20).

#### `proxyCreateTwin`
- **Bucket:** `twin_crud` · **Cost:** free (counts against tier's twin limit)
- **Body:** `{ name: string (≤100), description?, imageUrl?, referenceImageUrls?: string[] (≤5), ethnicity?, gender?, ageRange?, voiceProfile?, visualAesthetic?, contentStyle? }`
- **Returns:** new Twin object with `id`, server timestamps.

#### `proxyUpdateTwin`
- **Bucket:** `twin_crud` · **Cost:** free
- **Body:** `{ twinId: string, updates: { name?, description?, imageUrl?, referenceImageUrls?, voiceProfile?, visualAesthetic?, contentStyle?, ethnicity?, gender?, ageRange? } }`
- **Returns:** `{ success: true }`

#### `proxyDeleteTwin`
- **Bucket:** `twin_crud` · **Cost:** free
- **Body:** `{ twinId: string }`
- **Returns:** `{ success: true }`
- Cascade-cleans up an associated cloned voice if present (best-effort, non-fatal).

#### `proxyAnalyzeVoice`
Uploads an audio sample to Firebase Storage and generates a vivid voice description prompt for downstream video generation.

- **Bucket:** `twin_crud` · **Cost:** **3 credits when called via API** (free when called from the web UI)
- **Body:** `{ twinId: string, audioBase64: string (≤ ~24 MB raw, bounded by the 32 MB HTTP body limit), mimeType?: "audio/mpeg"|"audio/wav"|..., twinName? }`
- **Returns:** `{ voiceSampleUrl: string, generatedVoicePrompt: string }`
- The 3-credit charge for API calls reflects real Gemini multimodal COGS. UI users get this for free during onboarding so they can re-record without being penalized.

#### `proxyAnalyzeReferenceVideo`
Analyzes a reference video uploaded to Firebase Storage. Two modes: standard (fast, shallow) and `clone-video` (deep — high thinking, keyframe extraction).

- **Bucket:** `general` · **Cost:** 3 credits standard, 4 credits `clone-video` deep
- **Body:** `{ storagePath: string (must start with "projects/{uid}/reference-videos/"), mimeType?: "video/mp4"|"video/webm"|"video/quicktime", projectId: string, mode?: "clone-video"|"standard" }`
- **Returns:** `{ referenceVideoUrl: string, analysis: { scenes: [...], overallTone, aesthetic, environment, targetAudience, viralityFactors, contentFormat, estimatedDuration, hasDialogue, hasFaceOnCamera, audioStyle, mannerisms, editingStyle, musicOrSoundDescription, hookAnalysis, productPresence, speechCadence, vocalCharacter, signatureMoments, beatSync, cameraCapture, analysisVersion: 1|2, keyframes?: [{sceneTimestamp, startKeyframeUrl, endKeyframeUrl}] } }`
- Upload the video to Firebase Storage at `projects/{uid}/reference-videos/<file>` first — this endpoint reads from Storage rather than accepting body uploads, so the 32 MB body limit doesn't apply.

### Social helpers

#### `proxyGenerateSocialCaptions`
Platform-specific captions and hashtags.

- **Bucket:** `general` · **Cost:** free (no per-call deduction; subject to rate limit)
- **Body:** `{ scenario: string, platforms: ("instagram"|"tiktok"|"facebook"|"twitter"|"linkedin")[], twinDescription?, contentPillars?, nicheTopics?, tone?, preferredTones?, voiceDescription?, digitalProducts?, promotedProduct? }`
- **Returns:** `[{ platform, caption: string, hashtags: string[] }]` — one entry per requested platform.

---

## Pricing reference

Engine-specific credit costs. Each engine's std/HQ baseline reflects its retail provider COGS plus our margin. The `std` quality maps to each engine's faster, cheaper variant; `hq` maps to the premium variant.

| Action | Credits |
|---|---|
| Trend analysis / script / image standard / text overlay | 1 |
| Image HQ / twin image | 2 |
| Video analysis (standard) | 3 |
| Video analysis (clone-video deep) / **API voice analysis** | 4 / 3 |

### Video render costs

| Engine | Std (baseline) | HQ (baseline) | 4K / Ultra (baseline) | Duration scaling |
|---|---|---|---|---|
| **Veo 3.1** | 40 | 130 | — | fixed-cost regardless of duration |
| **Kling 3.0** | 32 (6.4s) | 50 (6.4s) | **130 (6.4s)** — native 4K | linear, divisor 6.4 |
| **Seedance 2.0 / 2.5** | 18 (4s) | 35 (4s) | **75 (4s)**, 60 at launch pricing through Oct 31 — Seedance 2.5 (720p, `bytedance/seedance-2.5/*`) | linear, divisor 4 |
| **Gemini Omni Flash** (`omni`, preview) | 40 (8s) | 40 (8s) — 720p only, no HQ tier | — | linear, divisor 8 |

For duration-scaled engines: `cost = round(baseline * duration / divisor)`. Examples: Seedance HQ at 12s = `round(35 * 12/4)` = 105 credits; Kling std at 10s = `round(32 * 10/6.4)` = 50 credits; Kling 4K at 8s = `round(130 * 8/6.4)` = 163 credits. Veo cost is the same whether you render 4s, 6s, or 8s — so on duration alone, Veo Std actually wins for longer clips.

**Engine selection guidance.** All four engines are roughly cost-comparable for an 8s 720p std render at the 10K-pack rate; pick on output quality, audio handling, and feature support rather than cost:

| Use case | Recommended engine |
|---|---|
| Faceless / hands-only / product-only | **Seedance 2.0** (text-to-video and reference modes) |
| Strong character consistency across scenes | **Veo 3.1 HQ** (native subject reference + audio) |
| Cost-sensitive bulk render with audio | **Veo 3.1 Std** (40 credits flat — no duration penalty) |
| Image-to-video with strong motion | **Kling 3.0** |
| Native 4K (no upscaling) | **Kling 3.0 4K** (`modelName: fal-ai/kling-video/v3/4k/image-to-video`) |
| Newest ByteDance model — single-take coherence, richer synced audio | **Seedance 2.5** (`modelName: bytedance/seedance-2.5/image-to-video`; billed as the seedance `ultra` tier, 75 base) |

### Credit packs (same prices for everyone)

| Pack | Credits | Price | $/credit |
|---|---|---|---|
| 200 | 200 | $25 | $0.125 |
| 500 | 500 | $50 | $0.10 |
| 2,500 | 2,500 | $200 | $0.08 |
| 10,000 | 10,000 | $700 | $0.07 |

Subscriber monthly allowances stack on top of any packs (creator 500, pro 1,800, business 4,000). Subscribing also drops the effective per-credit rate (creator $0.0580, pro $0.0439, business $0.0373).

---

## Not in v1

We're explicit about what's missing so you can plan around it.

- **Webhook callbacks on job completion.** Polling is the only completion notification in v1. Persist `operationName` and poll. `proxyCancelVideoGeneration` is a v2 candidate.
- **Refunds for abandoned video operations.** Credits are deducted at `proxyStartVideoGeneration`. If your client crashes after start but before completion, we don't refund. Use `Idempotency-Key` to prevent duplicates and a durable queue/job table to track in-flight operations.
- **Batch endpoints.** One job per call. To render N scenes, fan out N concurrent calls within your tier's concurrency cap.
- **Sandbox / test-mode keys.** Only `ugc_live_` keys exist. There is no `ugc_test_` prefix. To test, use a Creator-tier subscription (lowest commitment) and the existing endpoints.
- **Programmatic key rotation.** Create a new key in the UI, deploy it, then revoke the old one. Automated rotation is a v2 item.
- **Cursor pagination on all endpoints.** Only `proxyGetVideoLibrary` is paginated today. List endpoints (Twins, API keys via the management surface) return everything in one call. Hard caps prevent unbounded responses.
- **Official SDKs (JS/Python/Go).** Use the `fetch`/`requests` recipes above. We will publish OpenAPI + SDKs once endpoint contracts stabilize.
- **Signed-URL upload endpoints for assets larger than 32 MB.** Body limit is ~32 MB. For larger uploads, write to Firebase Storage at `projects/{uid}/reference-videos/<file>` and pass `storagePath` to `proxyAnalyzeReferenceVideo`. Direct signed-URL upload is a v2 candidate.
- **Permission scoping on keys.** Every key currently has full account access (`permissions: ["*"]`). Per-endpoint scopes are reserved for v2.
- **Webhook for billing events** (subscription change, credit-pack purchase). Use Stripe's customer portal for now; subscription state is reflected in `X-Credits-Remaining` on the next call.

---

## Support

- **Quote `request_id`** from the response headers in any support ticket. It's the only way we can find the exact log line.
- Web UI usage dashboard: **Profile → API Keys → Monthly Usage** (per-key + aggregate).
- Email: support@ugccopilot.ai.

---

## Changelog

- **2026-04-28 — v1 launch.** 26 allow-listed endpoints. Concurrency caps lifted (creator 3, pro 8, business 25). Volume credit packs added (2,500 / 10,000). `proxyAnalyzeVoice` charges 3 credits for API callers (UI free).
