# Polf API — Documentação completa (llms-full.txt) > API B2B do Polf (polf.ai): transforma vídeo longo em cortes verticais (Reels/TikTok/Shorts) com legenda PT-BR queimada. REST + JSON, assíncrona. Contrato compatível com a Vizard (mesmos nomes no create/query) + extras v2. Versão do contrato (apiVersion): 2026-07-01. Docs navegáveis: https://app.polf.ai/docs/api — OpenAPI: https://app.polf.ai/api/openapi.json - Base URL: `https://api.polf.ai` - Autenticação: header `INSTACUT_API_KEY: ik_SUA_CHAVE` em toda chamada. Chave ausente/inválida → HTTP 401. A chave e o `webhook_secret` (whsec_..., para verificar assinatura de webhook) são entregues UMA única vez — guarde num secret manager. Nunca chame a API do front-end. - Compatibilidade: o contrato só evolui de forma ADITIVA. Nomes publicados (campos, eventos, errorCodes, fontes polf-test://) nunca mudam; campos novos podem aparecer sem aviso. ## Workflow 1. Create — `POST /api/v1/project/create` com a videoUrl (YouTube, Google Drive ou mp4 direto). Resposta imediata com projectId; processamento assíncrono. 2. Acompanhe — registre `webhookUrl` no create (recomendado) e receba project.selected → clip.rendered (1 por clip) → project.completed. Sem webhook: polling do query a cada 20–30s. Polling é o fallback confiável — reconcilie por ele se um webhook se perder. 3. Baixe — cada clip tem `downloadUrl` DURÁVEL (não expira) e `streamUrl` presigned pra tocar (~1h). 4. (Opcional) Publique — conecte contas sociais do usuário final e poste/agende via `POST /api/v1/posts`. Estados do projeto (`status`, string): queued → processing → done | failed. `stage`: download → transcribe → select → render. Progresso honesto = `clipsReady`/`clipsTotal` (contagem real; não emitimos porcentagem). ## Quickstart (curl) ```bash # 1) Cria o projeto curl -s -X POST https://api.polf.ai/api/v1/project/create \ -H "Content-Type: application/json" \ -H "INSTACUT_API_KEY: ik_SUA_CHAVE" \ -d '{ "videoUrl": "https://www.youtube.com/watch?v=XXXX", "lang": "pt", "maxClipNumber": 6, "idempotencyKey": "meu-job-001", "webhookUrl": "https://seu-app.com/webhooks/polf" }' # → {"code": 2000, "projectId": "3021", "errMsg": ""} # 2) Poll a cada 20-30s (ou espere os webhooks) curl -s https://api.polf.ai/api/v1/project/query/3021 \ -H "INSTACUT_API_KEY: ik_SUA_CHAVE" # processando → {"code": 1000, "status": "processing", "stage": "render", "clipsReady": 2, "clipsTotal": 6, ...} # pronto → {"code": 2000, "status": "done", "videos": [...], ...} # 3) Baixa o mp4 pelo link DURÁVEL (-L segue o 302) curl -L -o clip.mp4 -H "INSTACUT_API_KEY: ik_SUA_CHAVE" \ "https://api.polf.ai/api/v1/clips/CLIP_ID/download" ``` Teste sem gastar crédito: `"videoUrl": "polf-test://success"` (ver Sandbox). ## POST /api/v1/project/create Campos ([hint] = aceito por compat-Vizard, NÃO altera a saída hoje): | Campo | Tipo | Descrição | |---|---|---| | videoUrl | string, obrigatório | YouTube/Drive/mp4 direto; aceita polf-test://... (sandbox) | | lang | string | idioma da transcrição/legenda (default "pt", PT-BR nativo) | | externalUserId | string | ID do SEU usuário final (multi-tenant) — ecoado em toda resposta/webhook; entra na idempotência default | | idempotencyKey | string | dedupe anti-cobrança-dupla: reenvio com a MESMA chave devolve o MESMO projeto sem recobrar. Sem ela, chave default = hash estável dos inputs (fonte+janela+seleção) | | webhookUrl | string | recebe webhooks v2 + aviso v1; precisa ser host público (anti-SSRF) | | maxClipNumber | int 1–100 | top-N clips por nota (fora da faixa → code 4006) | | ratioOfClip | int 1–4 | 1=9:16, 2=1:1, 3=4:5, 4=16:9 | | preferLength | int[] | 0 auto, 1 <30s, 2 30–60, 3 60–90, 4 >90s (best-effort) | | keyword | string | clipagem direcionada (foca a seleção num tema) | | videoQuality | string | "720p" (≈metade do tempo) ou "1080p" (default) | | removeSilenceSwitch | int 0/1 | remove silêncio/filler | | headlineSwitch / emojiSwitch | int 0/1 | headline por IA / emoji na legenda | | captionPreset | string | word-focus/vizard/hype/minimalist/line-focus | | captionAnim | string | karaoke/word_pop/typewriter/bounce/fade | | clipFormat | string | full/bars/blur | | headlineStyle | string | vermelha/branca/rasgada/pincelada/fita | | processStartS / processDurationS | float | janela da fonte (capada a teto server-side) | | projectName | string | nome do projeto | | videoType / ext / subtitleSwitch / highlightSwitch / templateId | [hint] | compat-Vizard, não aplicados | Resposta: `{"code": 2000, "projectId": "3021", "shareLink": null, "errMsg": "", "externalUserId": "user_42"}` Cobrança por segundos de fonte processados, reservados atomicamente no create (sem crédito → code 4007, nada é criado). ## GET /api/v1/project/query/{projectId} Envelope compat-Vizard + campos v2 (status/stage/clipsReady/clipsTotal/externalUserId/error). - code 1000 = processando (continue polling) - code 2000 = pronto; `videos[]` pode vir vazio = "sem cortes bons" (ainda é sucesso; clipsTotal 0) - code 4006 = projeto não encontrado (ou não é da sua chave) - code 4xxx = falhou (campo `error` traz errorCode/message/retriable/stage) Objeto de `videos[]` (nomes Vizard + extras): | Campo | Descrição | |---|---| | videoId | id do clip (use no /export e /download) | | title / transcript / videoMsDuration | metadados do clip | | viralScore | STRING 0–10 (ex "8.5", "9") — nota honesta de montagem, não previsão de viral | | viralReason | por que foi selecionado | | ready | false = selecionado mas mp4 ainda queimando (poll de novo ou force /export) | | videoUrl / streamUrl | presigned pra tocar — EXPIRA ~1h, não persista | | downloadUrl | DURÁVEL: https://api.polf.ai/api/v1/clips/{clipId}/download — persista este | | thumbUrl / thumbnailUrl | miniatura presigned (existe desde a seleção) | | status | selected / rendering / rendered / failed | | hook / socialCaption | gancho e legenda social prontos (extras Polf) | | layoutType / captionLang / clipEditorUrl / relatedTopic / disliked / starred | extras/compat | Exemplo de falha: ```json { "code": 4008, "projectId": "3022", "videos": [], "errMsg": "Não conseguimos baixar este vídeo — ele pode estar privado, removido, restrito por idade/região, ou ser muito longo. Confira o link e as permissões.", "status": "failed", "stage": "download", "externalUserId": "user_42", "clipsReady": 0, "clipsTotal": 0, "error": { "errorCode": "SOURCE_UNAVAILABLE", "message": "…mesma mensagem PT…", "retriable": false, "stage": "download" } } ``` ## GET /api/v1/clips/{clipId}/download (download durável, v2) Link que NÃO expira: responde 302 → presigned fresca (1h) com `Content-Disposition: attachment`. Use `curl -L`. - 302 = redirect pro arquivo - 409 = clip ainda sem mp4 (dispare o export e re-tente) - 410 = passou da retenção de 30 dias (reprocesse o projeto) - 404 = clip não encontrado / não é da sua chave ## POST /api/v1/clip/{clipId}/export Garante a queima do mp4 de UM clip (quando o query deu videoUrl null). Idempotente: re-POST devolve o MESMO renderJobId (determinístico: `clipId:9x16`) com o status atual, sem re-queimar nem recobrar. Resposta: `{"code": 2000, "clipId": "…", "renderJobId": "…:9x16", "status": "exporting"}` — status ∈ done | exporting | failed. Acompanhe pelo query ou pelo webhook clip.rendered. ## GET /api/v1/projects (lista paginada) Query params: `externalUserId` (filtra por usuário final), `status` (queued|processing|done|failed), `limit` (1–50, default 20), `cursor` (opaco; repasse o nextCursor da página anterior; null = acabou). Resposta: `{"code": 2000, "projects": [{projectId, projectName, createdAt, status, stage, externalUserId, clipsReady, clipsTotal, error?}], "nextCursor": "..." | null}` ## GET /api/v1/usage (uso & saldo self-service) Meça seu consumo sem sair da API. Créditos em MINUTOS de vídeo fonte (moeda da quota). Resposta: `{"code": 1000, "credits": {"unit": "source_minutes", "used": 42.5, "limit": 300, "remaining": 257.5, "period": "month", "periodStart": "..."}, "posts": {"month": 12, "limit": 300} | null, "suspended": false}` Mesma informação via MCP: tool `get_credits`. ## Publicação social (em rollout) Enquanto não liberada pra sua chave: HTTP 503 "publicação social via API em breve". Contrato definitivo: - `GET /api/v1/users/{externalUserId}/social-accounts` → `{accounts: [{accountId, platform, username, displayName, status}]}`; status ∈ connected | pending_channel (falta escolher página/canal — refaça a conexão) - `POST /api/v1/users/{externalUserId}/social-accounts/connect` body `{returnUrl, platform?}` → `{connectUrl}` (portal OAuth hospedado; sem platform = portal com todas as redes) - `POST /api/v1/posts` body `{externalUserId, clipId, targets: [{accountId? | platform?, caption?}], scheduledAt?, webhookUrl?}` → `{postId, status, scheduledAt, targets: [{targetId, platform, status, platformPostUrl}]}`. caption ausente = socialCaption do clip; scheduledAt ausente = publica já; scheduledAt `"auto"` = o Polf agenda no próximo horário SEGURO da conta (a resposta ecoa o horário resolvido em scheduledAt). Exige clip renderizado (409) e contas conectadas (409). Cota de posts/mês por chave → HTTP 402 ao estourar. - `GET /api/v1/posts/{postId}` → status por alvo com platformPostUrl real e erro `{errorCode, message, retriable}` repassado da rede - `DELETE /api/v1/posts/{postId}` → cancela post AGENDADO (publicado continua no ar) - `GET /api/v1/posts?externalUserId=&status=&cursor=&limit=` → lista paginada (mesmo cursor keyset) - `GET /api/v1/posts/{postId}/metrics` → views/likes/… por alvo, espelhadas 1×/dia (X/Twitter não expõe analytics) Status do post: processing | scheduled | published | partial | failed | draft | canceled (partial = publicou em parte das redes). targetId determinístico: `postId:REDE`. ### Travas de postagem & conta aquecida Pra proteger a conta do usuário final de flag/ban, o Polf aplica travas seguras POR CONTA e POR REDE: teto de posts/dia, espaçamento mínimo entre posts e rampa de aquecimento pra conta recém-conectada (teto começa em 1 post/dia e sobe a cada semana, chegando ao padrão da rede em ~14 dias). São proteções padrão Polf — nenhuma rede publica esses números oficialmente. - Estourou a trava → `HTTP 429` com `{"errorCode": "RATE_PROTECTED", "retriable": true, "nextSlot": ""}`: transitório — re-tente no nextSlot, ou mande `scheduledAt: "auto"`. - `scheduledAt: "auto"` resolve o próximo horário que respeita teto/espaçamento/rampa de TODAS as redes do post; a resposta ecoa o horário escolhido em `scheduledAt`. ## Webhooks v2 Registre `webhookUrl` no create. Envelope de TODO evento: ```json { "event": "clip.rendered", "eventId": "uuid (dedup — entrega at-least-once)", "createdAt": "2026-07-02T14:07:31+00:00", "apiVersion": "2026-07-01", "projectId": "3021", "externalUserId": "user_42", "data": { } } ``` Regras: dedup por eventId; ORDEM NÃO GARANTIDA (trate eventos de forma independente); responda 2xx em <15s e processe async; retry nosso: 3 tentativas na hora (imediata, +5s, +30s) + re-entrega em background por até 24h (a cada ~15min, máx 8 tentativas no total, MESMO eventId). Todo projeto termina com evento terminal (project.completed ou project.failed) — inclusive quando o processamento é interrompido no servidor. O polling segue como fallback final. Objeto CLIP CANÔNICO (mesmo shape em webhooks e query): ```json { "clipId": "8a1f0c02-…", "projectId": "3021", "title": "O erro que derruba 9 em cada 10 canais", "hook": "Você está fazendo isso errado", "socialCaption": "O corte que muda o jogo #cortes", "viralScore": "8.5", "viralReason": "gancho forte nos 3 primeiros segundos", "durationMs": 42000, "ratio": "9:16", "captionLang": "pt", "status": "rendered", "streamUrl": "https://…presigned (expira ~1h; null enquanto selected)", "downloadUrl": "https://api.polf.ai/api/v1/clips/8a1f0c02-…/download", "thumbnailUrl": "https://…presigned (existe desde a seleção)", "width": null, "height": null, "sizeBytes": null } ``` status do clip ∈ selected | rendering | rendered | failed. width/height/sizeBytes ainda não medidos (null honesto). Eventos e `data`: - `project.selected` — seleção pronta (mp4 ainda queimando): `{clips: [clip canônico sem mp4…], clipsTotal, clipsReady}` - `clip.rendered` — UM clip queimou (1 evento por clip): `{clip: {…status rendered, streamUrl, downloadUrl}, clipsReady, clipsTotal}` - `clip.failed` — a queima de UM clip falhou: `{clipId, error: {errorCode, message, retriable, stage}}` - `project.completed` — projeto inteiro queimado (ou sucesso-vazio sem cortes): `{clipsReady, clipsTotal}` - `project.failed` — falha PERMANENTE (transitórias re-tentam sozinhas antes): `{error: {errorCode, message, retriable, stage}}` - `post.published` — post social publicado (inclui partial): `{postId, status, targets: [{targetId, platform, status, platformPostUrl, error?}]}` - `post.failed` — post falhou em todas as redes: mesmo shape Webhook v1 legado (continua saindo na MESMA URL, sem envelope): `{"code": 2000, "projectId": "3021", "jobId": "uuid", "clips": 6}` — clips 0 = sem cortes bons (ainda sucesso). Integrações novas: use os eventos v2. ## Assinatura HMAC (Polf-Signature) Header em todo webhook (v1 e v2): `Polf-Signature: t=,v1=` onde `v1 = HMAC-SHA256(webhook_secret, ".")` (Stripe-style). Verifique: corpo CRU (antes de parsear), comparação tempo-constante, janela anti-replay de 300s. Python: ```python import hmac, hashlib, time def verify_polf_signature(raw_body: bytes, header: str, secret: str, tol: int = 300) -> bool: p = dict(kv.split("=", 1) for kv in header.split(",")) if abs(time.time() - int(p["t"])) > tol: return False expected = hmac.new(secret.encode(), p["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, p["v1"]) ``` Node.js: ```js import crypto from "node:crypto"; export function verifyPolfSignature(rawBody, signatureHeader, secret, toleranceS = 300) { const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split(/=(.+)/).slice(0, 2))); if (!parts.t || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceS) return false; const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: use express.raw({ type: "application/json" }) nesta rota — a assinatura é do corpo CRU. ``` ## Sandbox (polf-test://) — contrato público Teste o fluxo INTEIRO (create, webhooks, polling, download E os erros) em CI, sem gastar crédito/GPU/download. Use como videoUrl: - `polf-test://success` — done em ~5s: 2 clips fake ("[SANDBOX] …") com mp4/downloadUrl REAIS (asset fixo 3s 9:16); webhooks na ordem de produção: project.selected → clip.rendered ×2 → project.completed - `polf-test://fail-source` — ~2s → project.failed `{errorCode: "SOURCE_UNAVAILABLE", retriable: false}` - `polf-test://fail-render` — project.selected ok → clip.failed → project.failed `{errorCode: "RENDER_FAILED", retriable: true}` - `polf-test://slow` — fica processing (stage render) por ~10min SEM evento terminal (teste seu teto anti-limbo) e então completa como o success Regras: custo zero real (não reserva/debita crédito); payloads levam `"sandbox": true` no data; sem idempotencyKey cada chamada de sandbox cria job NOVO (CI pode repetir a mesma URL); nomes dos modos nunca mudam (só adicionamos). ## Catálogo de erros errorCode v2 (string estável, em `error` de query/list/webhooks). `retriable: true` = transitório (vale reprocessar); false = permanente. Pra reprocessar um projeto falho: novo create com idempotencyKey NOVO (a chave antiga devolve o projeto falho). | errorCode | Quando | retriable típico | |---|---|---| | SOURCE_UNAVAILABLE | link inválido/privado/removido/restrito, corrompido, fonte expirada, download falhou | false (link ruim); true se o serviço de download caiu temporariamente | | NO_CREDITS | créditos insuficientes | false — recarregue | | LANGUAGE_UNSUPPORTED | vídeo não é em português (por enquanto só PT-BR) | false | | RENDER_FAILED | queima (render/legenda) falhou | geralmente true | | INTERNAL | falha interna (transcrição/seleção/interrupção) | varia — confie no payload | | NO_CLIPS | RESERVADO — hoje "sem cortes bons" é sucesso: status done, clipsTotal 0, videos [] | — | | PUBLISH_FAILED | (posts) a rede recusou a publicação | repassado da rede por alvo | | RATE_PROTECTED | (posts, HTTP 429) trava segura da conta atingida (teto/dia, espaçamento ou rampa de conta nova) — vem com nextSlot | true — re-tente no nextSlot ou use scheduledAt "auto" | code do envelope (compat-Vizard; HTTP 200 com {code} nos endpoints de clipping): | code | Significado | |---|---| | 2000 | sucesso | | 1000 | processando | | 4002 | falha ao enfileirar / clipagem falhou (reprocessável) | | 4003 | integração temporariamente indisponível (kill-switch) | | 4004 / 4005 | formato não suportado / arquivo corrompido | | 4006 | parâmetro inválido, vídeo longo demais ou não encontrado | | 4007 | créditos insuficientes | | 4008 / 4009 | download da fonte falhou / URL inválida | | 4010 | idioma não suportado | | 4099 | falha interna do pipeline | Endpoints novos (download, social) usam status HTTP semânticos: 302/400/401/402/404/409/410/429/503. ## Rate limits Por CHAVE (não por IP), janela 60s. Toda resposta autenticada de /api/v1/* leva os headers IETF `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (s até liberar 1 slot). Estourou → HTTP 429 com `Retry-After` (s) — respeite e re-tente. | Rotas | Teto | |---|---| | POST /api/v1/project/create | 20 req/min | | POSTs caros (/export, /publish…) | 20 req/min | | leitura B2B (query, projects, download, posts…) | 120 req/min | ## Retenção de arquivos - MP4s e fontes: 30 dias — depois o downloadUrl responde 410 Gone (reprocesse se precisar). Se o seu fluxo guarda vídeos, baixe e armazene do seu lado dentro da janela. - URLs presigned (streamUrl/videoUrl/thumbnailUrl): ~1 hora — só pra tocar/exibir. Persista o downloadUrl (durável) + clipId. - Metadados (projetos, clips, notas, transcrição): seguem consultáveis pelo query/list. - Métricas sociais: espelhadas 1×/dia, retenção 30 dias do provedor. ## Recursos - Docs navegáveis: https://app.polf.ai/docs/api - OpenAPI 3.1: https://app.polf.ai/api/openapi.json (gere tipos com openapi-typescript ou similar) - SDK TypeScript oficial (com webhooks.constructEvent): a caminho.