API do Polf

Transforme vídeo longo em cortes verticais (Reels/TikTok/Shorts) com legenda PT-BR queimada — por API. Você manda a URL do vídeo, a gente devolve os melhores cortes prontos, com título, gancho, legenda social e nota honesta de 0 a 10.

  • REST + JSON, assíncrona: create → webhook ou polling → download. Base: https://api.polf.ai.
  • Contrato compatível com a Vizard (mesmos nomes no create/query) — quem já integra Vizard troca a base URL e a chave.
  • Extras v2: webhooks nomeados e assinados (HMAC), link de download durável (não expira), idempotencyKey anti-cobrança-dupla, sandbox polf-test:// pra testar tudo em CI sem gastar crédito, e publicação social direto da API.
  • Multi-tenant: mande externalUserId (o ID do SEU usuário) no create — ele volta ecoado em toda resposta e webhook, pra você rotear sem consulta.

Precisa de uma chave? A API é liberada por cliente. Fale com a gente pelo app ou pelo e-mail de suporte — você recebe a INSTACUT_API_KEY e o webhook_secret (mostrados uma única vez).

Quickstart

Fluxo completo em 3 chamadas: criar → acompanhar → baixar. Cole no terminal trocando a chave e a URL:

curl — create → poll → download
# 1) Crie o projeto — o vídeo longo entra, os cortes saem
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) Acompanhe: por WEBHOOK (recomendado) ou polling a cada 20-30s
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) Baixe o mp4 pelo link DURÁVEL (não expira; -L segue o redirect 302)
curl -L -o clip.mp4 \
  -H "INSTACUT_API_KEY: ik_SUA_CHAVE" \
  "https://api.polf.ai/api/v1/clips/CLIP_ID/download"

Quer testar sem gastar crédito? Use "videoUrl": "polf-test://success" — o fluxo inteiro (webhooks, status, download) roda simulado em ~5 segundos. Veja Sandbox.

Autenticação

Toda chamada leva a sua chave no header INSTACUT_API_KEY (formato ik_…):

header de autenticação
curl https://api.polf.ai/api/v1/project/query/3021 \
  -H "INSTACUT_API_KEY: ik_SUA_CHAVE"
  • Chave ausente/inválida → HTTP 401.
  • Junto com a chave você recebe o webhook_secret (whsec_…), usado pra verificar a assinatura Polf-Signature dos webhooks — os dois são exibidos uma única vez; guarde num secret manager.
  • Nunca exponha a chave no front-end: chame a API só do seu servidor.

Workflow

  1. CreatePOST /api/v1/project/create com a videoUrl (YouTube, Google Drive ou mp4 direto). Resposta imediata com projectId; o processamento roda assíncrono.
  2. Acompanhe — registre um webhookUrl no create e receba os eventos project.selectedclip.rendered (um por clip) → project.completed. Sem webhook, faça polling do query a cada 20–30s. O polling é sempre o fallback confiável — reconcilie por ele se um webhook se perder.
  3. Baixe — cada clip tem um downloadUrl durável (não expira) e um streamUrl presigned pra tocar no seu player (expira ~1h).
  4. (Opcional) Publique — conecte as contas sociais do seu usuário final e poste/agende o clip por POST /api/v1/posts.

Estados do projeto (status, string): queued → processing → done | failed. O campo stage diz a fase atual (download → transcribe → select → render), e clipsReady/clipsTotal é contagem real (não inventamos porcentagem).

Criar projeto

POST/api/v1/project/create

Submete um vídeo longo pra clipagem. Nomes compatíveis com a Vizard; campos [hint] são aceitos por compatibilidade mas não alteram a saída hoje (honestidade > marketing).

CampoTipoDescrição
videoUrlstring · obrigatórioYouTube, Google Drive ou URL direta de mp4. Aceita as fontes mágicas polf-test://… do sandbox.
langstringIdioma da transcrição/legenda (default pt — PT-BR nativo).
externalUserIdstringID do SEU usuário final (multi-tenant). Ecoado em toda resposta e webhook; entra na chave de idempotência default.
idempotencyKeystringDedupe anti-cobrança-dupla: reenvio com a MESMA chave devolve o MESMO projeto, sem nova cobrança. Sem ela, geramos uma chave estável a partir dos inputs (fonte + janela + seleção) — retry de rede idêntico também não recobra.
webhookUrlstringRecebe os webhooks v2 + o aviso v1 de conclusão. Precisa ser host público (anti-SSRF).
maxClipNumberint 1–100Corta os top-N clips por nota. Fora da faixa → código 4006.
ratioOfClipint 1–41 = 9:16 · 2 = 1:1 · 3 = 4:5 · 4 = 16:9.
preferLengthint[]0 auto · 1 <30s · 2 30–60 · 3 60–90 · 4 >90s. Best-effort: prioriza a banda, não garante.
keywordstringClipagem direcionada: foca a seleção num tema/momento específico.
videoQualitystringQualidade do download da fonte: "720p" (≈metade do tempo) · "1080p" (default).
removeSilenceSwitchint 0/1Remove silêncio e filler words.
headlineSwitch / emojiSwitchint 0/1Headline/gancho por IA no topo do quadro · emoji na legenda.
captionPreset / captionAnimstringEstilo da legenda (word-focus/vizard/hype/minimalist/line-focus) e animação (karaoke/word_pop/typewriter/bounce/fade).
clipFormat / headlineStylestringLayout do quadro (full/bars/blur) · estilo da headline (vermelha/branca/rasgada/pincelada/fita).
processStartS / processDurationSfloatJanela: começa em N s / processa só N s da fonte (capado a um teto server-side). Economiza crédito em vídeos gigantes.
projectNamestringNome do projeto (aparece no query/list).
videoType / ext / subtitleSwitch / highlightSwitch / templateId[hint]Aceitos por compat-Vizard; NÃO alteram a saída hoje.
resposta
{
  "code": 2000,
  "projectId": "3021",
  "shareLink": null,
  "errMsg": "",
  "externalUserId": "user_42"
}

A cobrança é por segundos de fonte processados, reservados atomicamente no create (sem crédito → código 4007, nada é criado).

Consultar projeto

GET/api/v1/project/query/{projectId}

Devolve o envelope compat-Vizard (code + videos[]) MAIS os campos v2: status string, stage, clipsReady/clipsTotal, externalUserId ecoado e — na falha — o objeto error estruturado.

codeSignificado
1000Processando — continue o polling (20–30s). stage diz a fase.
2000Pronto. videos[] pode vir vazio (= não achamos corte bom o bastante; ainda é sucesso — clipsTotal: 0 sinaliza).
4006Projeto não encontrado (ou não é da sua chave).
4xxxFalhou — veja o catálogo de erros (o campo error traz errorCode/retriable).
resposta — pronto (code 2000)
{
  "code": 2000,
  "projectId": "3021",
  "projectName": "Integração",
  "status": "done",
  "stage": "render",
  "externalUserId": "user_42",
  "clipsReady": 6,
  "clipsTotal": 6,
  "videosReady": 6,
  "videosTotal": 6,
  "videos": [
    {
      "videoId": "8a1f0c02-7e11-4a9e-9a71-3f2f6f1b2c9d",
      "title": "O erro que derruba 9 em cada 10 canais",
      "videoMsDuration": 42000,
      "viralScore": "8.5",
      "viralReason": "gancho forte nos 3 primeiros segundos",
      "transcript": "…",
      "ready": true,
      "videoUrl": "https://…presigned… (expira ~1h)",
      "streamUrl": "https://…presigned… (expira ~1h)",
      "downloadUrl": "https://api.polf.ai/api/v1/clips/8a1f0c02-…/download",
      "thumbUrl": "https://…presigned…",
      "thumbnailUrl": "https://…presigned…",
      "status": "rendered",
      "hook": "Você está fazendo isso errado",
      "socialCaption": "O corte que muda o jogo #cortes",
      "layoutType": "fill",
      "captionLang": "pt",
      "clipEditorUrl": "/editor?clip=8a1f0c02-…",
      "relatedTopic": "[]",
      "disliked": false,
      "starred": false
    }
  ]
}

ready: false em um clip = selecionado mas o mp4 ainda está queimando (chega sozinho; ou force com o export). videoUrl/streamUrl são presigned e expiram (~1h) — pra guardar/baixar use sempre o downloadUrl durável.

resposta — falha (code 4xxx + error v2)
{
  "code": 4008,
  "projectId": "3022",
  "projectName": "Integração",
  "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": "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.",
    "retriable": false,
    "stage": "download"
  }
}

Download durável v2

GET/api/v1/clips/{clipId}/download

O link que não expira: responde 302 pra uma URL presigned fresca (1h) com Content-Disposition: attachment (no celular o arquivo salva em vez de abrir numa aba). É o downloadUrl que o query e os webhooks entregam — guarde ele no seu banco em vez do presigned.

HTTPSignificado
302Redirect pra presigned fresca — use curl -L / siga o redirect.
409Clip existe mas ainda sem mp4 — dispare o export e tente de novo.
410Arquivo passou da retenção de 30 dias — reprocesse o projeto.
404Clip não encontrado (ou não é da sua chave).

Export de um clip

POST/api/v1/clip/{clipId}/export

Garante a queima do mp4 de UM clip (quando o query devolveu videoUrl: null). Idempotente: re-POST não re-queima nem recobra — devolve o mesmo renderJobId (determinístico, clipId:formato) com o status atual.

resposta
{
  "code": 2000,
  "clipId": "8a1f0c02-…",
  "renderJobId": "8a1f0c02-…:9x16",
  "status": "exporting"
}

statusdone | exporting | failed. Acompanhe pelo query (ou pelo webhook clip.rendered) até videoUrl != null.

Listar projetos

GET/api/v1/projects

Lista paginada dos projetos da sua chave (cursor keyset, estável sob inserção — padrão Stripe/GitHub).

Query paramDescrição
externalUserIdFiltra pelos projetos de UM usuário final seu.
statusqueued | processing | done | failed.
limit1–50 (default 20).
cursorOpaco. Repasse o nextCursor da página anterior; nextCursor: null = acabou.
resposta
{
  "code": 2000,
  "projects": [
    {
      "projectId": "3021",
      "projectName": "Integração",
      "createdAt": "2026-07-02T14:03:07+00:00",
      "status": "done",
      "stage": "render",
      "externalUserId": "user_42",
      "clipsReady": 6,
      "clipsTotal": 6
    },
    {
      "projectId": "3020",
      "projectName": "Integração",
      "createdAt": "2026-07-02T11:41:22+00:00",
      "status": "failed",
      "stage": "download",
      "externalUserId": "user_17",
      "clipsReady": 0,
      "clipsTotal": 0,
      "error": {
        "errorCode": "SOURCE_UNAVAILABLE",
        "message": "O link enviado é inválido ou não aponta para um vídeo.",
        "retriable": false,
        "stage": "download"
      }
    }
  ],
  "nextCursor": "MjAyNi0wNy0wMlQxMTo0MToyMiswMDowMHwzMDIw",
  "errMsg": ""
}

Uso & saldo

GET/api/v1/usage

Meça seu próprio consumo sem sair da API: créditos em minutos de vídeo fonte (usados / limite / restantes no período) e, quando a publicação social está habilitada pra sua chave, posts do mês vs cota. Cheque o saldo antes de criar um projeto grande.

resposta
{
  "code": 1000,
  "credits": {
    "unit": "source_minutes",
    "used": 42.5,
    "limit": 300,
    "remaining": 257.5,
    "period": "month",
    "periodStart": "2026-07-01T00:00:00+00:00"
  },
  "posts": { "month": 12, "limit": 300 },
  "suspended": false
}

Publicação social em rollout

Poste ou agende um clip nas redes conectadas do SEU usuário final (TikTok, Instagram, YouTube, Facebook, LinkedIn, X…) sem sair da API. Em rollout controlado: enquanto não estiver liberada pra sua chave, os endpoints respondem HTTP 503 — “publicação social via API em breve”. O contrato abaixo é o definitivo.

Conectar contas (OAuth hospedado)

GET/api/v1/users/{externalUserId}/social-accounts
POST/api/v1/users/{externalUserId}/social-accounts/connect

O connect recebe { "returnUrl": "https://seu-app.com/volta", "platform": "tiktok"? } e devolve uma connectUrl — redirecione seu usuário pra ela (portal OAuth hospedado; sem platform abre o portal com todas as redes). Depois, o GET lista as contas: { accountId, platform, username, displayName, status } com statusconnected | pending_channel (OAuth ok mas falta escolher a página/canal — refaça a conexão).

Publicar / agendar

POST/api/v1/posts
criar post (multi-rede, agendado)
curl -s -X POST https://api.polf.ai/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "INSTACUT_API_KEY: ik_SUA_CHAVE" \
  -d '{
    "externalUserId": "user_42",
    "clipId": "8a1f0c02-…",
    "targets": [
      { "platform": "tiktok" },
      { "accountId": "sa_123", "caption": "legenda específica desta rede" }
    ],
    "scheduledAt": "2026-07-03T18:00:00-03:00",
    "webhookUrl": "https://seu-app.com/webhooks/polf"
  }'
# → { "code": 2000, "postId": "p_9f31…", "status": "scheduled",
#     "scheduledAt": "2026-07-03T21:00:00+00:00",
#     "targets": [ { "targetId": "p_9f31…:TIKTOK", "platform": "tiktok",
#                    "status": "processing", "platformPostUrl": null } ] }
  • targets[]: cada alvo aponta accountId (preferido) OU platform; caption ausente = usa a socialCaption do clip.
  • scheduledAt ausente = publica já; "auto" = o Polf escolhe o próximo horário SEGURO da conta (a resposta devolve o horário resolvido). Agendado dá pra cancelar com DELETE /api/v1/posts/{postId}.
  • O clip precisa estar renderizado (409 caso contrário — dispare o export antes) e o usuário precisa ter conectado contas (409 com instrução).
  • Cota de posts/mês por chave: estourou → HTTP 402.

Travas de postagem & conta aquecida

Pra proteger a conta do seu 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 (o 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 publicando “agora” → HTTP 429 com { "errorCode": "RATE_PROTECTED", "retriable": true, "nextSlot": "…" }: é transitório — re-tente no nextSlot, ou mande scheduledAt: "auto" pra agendar direto no próximo horário seguro.
  • scheduledAt: "auto" resolve o próximo horário que respeita teto, espaçamento e rampa de TODAS as redes do post — a resposta ecoa o horário escolhido em scheduledAt.

Acompanhar

GET/api/v1/posts/{postId}
GET/api/v1/posts
DELETE/api/v1/posts/{postId}
GET/api/v1/posts/{postId}/metrics

Status do post: processing | scheduled | published | partial | failed | draft | canceled (partial = publicou em parte das redes; o detalhe por rede vem em targets[], com platformPostUrl real e erro { errorCode, message, retriable } repassado da rede). A lista pagina igual /api/v1/projects (cursor + filtros externalUserId/status). As métricas (views/likes/… por alvo) são espelhadas 1×/dia; X (Twitter) não expõe analytics.

Webhooks

Registre um webhookUrl no create e receba POSTs a cada transição relevante — sem polling. Todo evento v2 usa o mesmo envelope:

envelope v2 (todo evento)
{
  "event": "clip.rendered",
  "eventId": "b1e6a7a2-4a4e-4c1b-9d2f-8f0f4b6a2e11",
  "createdAt": "2026-07-02T14:07:31+00:00",
  "apiVersion": "2026-07-01",
  "projectId": "3021",
  "externalUserId": "user_42",
  "data": { }
}
  • Entrega at-least-once: deduplique por eventId. A ordem NÃO é garantida (um clip.rendered pode chegar antes do project.selected) — trate cada evento de forma independente.
  • Responda 2xx rápido (menos de 15s) e processe async. Falhou? A gente re-tenta 3× na hora (imediata, +5s, +30s) e depois segue re-tentando em background por até 24 horas (a cada ~15min, até 8 tentativas no total) — o MESMO eventId em toda re-entrega, então sua deduplicação já resolve. O polling continua sendo o fallback final — reconcilie pelo query.
  • Todo projeto termina com um evento terminal: project.completed ou project.failed — inclusive quando o processamento é interrompido no servidor. Você nunca fica esperando pra sempre.
  • apiVersion (2026-07-01): campos novos podem ser adicionados sem aviso; nomes existentes nunca mudam.

O objeto clip canônico

O MESMO shape em todos os eventos e no query (nomes idênticos — sem surpresa thumbUrl×thumbnailUrl):

clip canônico
{
  "clipId": "8a1f0c02-7e11-4a9e-9a71-3f2f6f1b2c9d",
  "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 status=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
}

statusselected | rendering | rendered | failed. Antes de rendered, streamUrl é null (o thumbnail existe desde a seleção). width/height/sizeBytes ainda não são medidos — null honesto por enquanto.

Eventos

EventoQuando disparadata
project.selectedA seleção terminou: os cortes foram escolhidos (mp4 ainda queimando).clips[] (canônicos, sem mp4) + clipsTotal/clipsReady
clip.renderedUM clip terminou de queimar (dispara por clip).clip completo (com streamUrl/downloadUrl) + clipsReady/clipsTotal
clip.failedA queima de UM clip falhou.clipId + error
project.completedTodos os clips do projeto queimados (ou sucesso-vazio, sem cortes bons).clipsReady/clipsTotal
project.failedFalha PERMANENTE do projeto (transitórias re-tentam sozinhas antes).error (errorCode/message/retriable/stage)
post.publishedPost social chegou em terminal publicado (inclui parcial).postId/status/targets[]
post.failedPost social falhou em todas as redes.postId/status/targets[] (erro por rede)
project.selected
{
  "event": "project.selected",
  "eventId": "…",
  "createdAt": "2026-07-02T14:05:02+00:00",
  "apiVersion": "2026-07-01",
  "projectId": "3021",
  "externalUserId": "user_42",
  "data": {
    "clips": [ { "clipId": "…", "status": "selected", "streamUrl": null,
                 "thumbnailUrl": "https://…", "viralScore": "8.5", "…": "…" } ],
    "clipsTotal": 6,
    "clipsReady": 0
  }
}
clip.rendered
{
  "event": "clip.rendered",
  "eventId": "…",
  "createdAt": "2026-07-02T14:07:31+00:00",
  "apiVersion": "2026-07-01",
  "projectId": "3021",
  "externalUserId": "user_42",
  "data": {
    "clip": { "clipId": "…", "status": "rendered",
              "streamUrl": "https://…presigned…", "downloadUrl": "https://…/download", "…": "…" },
    "clipsReady": 1,
    "clipsTotal": 6
  }
}
clip.failed
{
  "event": "clip.failed",
  "eventId": "…",
  "projectId": "3021",
  "externalUserId": "user_42",
  "apiVersion": "2026-07-01",
  "createdAt": "…",
  "data": {
    "clipId": "8a1f0c02-…",
    "error": {
      "errorCode": "RENDER_FAILED",
      "message": "Falha ao renderizar os cortes. Tente gerar de novo.",
      "retriable": true,
      "stage": "render"
    }
  }
}
project.completed
{
  "event": "project.completed",
  "eventId": "…",
  "projectId": "3021",
  "externalUserId": "user_42",
  "apiVersion": "2026-07-01",
  "createdAt": "…",
  "data": { "clipsReady": 6, "clipsTotal": 6 }
}
project.failed
{
  "event": "project.failed",
  "eventId": "…",
  "projectId": "3022",
  "externalUserId": "user_42",
  "apiVersion": "2026-07-01",
  "createdAt": "…",
  "data": {
    "error": {
      "errorCode": "SOURCE_UNAVAILABLE",
      "message": "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.",
      "retriable": false,
      "stage": "download"
    }
  }
}
post.published (com falha parcial por rede)
{
  "event": "post.published",
  "eventId": "…",
  "projectId": null,
  "externalUserId": "user_42",
  "apiVersion": "2026-07-01",
  "createdAt": "…",
  "data": {
    "postId": "p_9f31…",
    "status": "published",
    "targets": [
      { "targetId": "p_9f31…:TIKTOK", "platform": "tiktok",
        "status": "published", "platformPostUrl": "https://www.tiktok.com/@user/video/…" },
      { "targetId": "p_9f31…:INSTAGRAM", "platform": "instagram",
        "status": "failed", "platformPostUrl": null,
        "error": { "errorCode": "PUBLISH_FAILED",
                   "message": "…detalhe da rede…", "retriable": true } }
    ]
  }
}

Webhook v1 (legado, continua saindo)

Além dos eventos v2, a MESMA URL recebe o aviso magro de conclusão do contrato original (sem envelope, sem event):

webhook v1 de conclusão
{ "code": 2000, "projectId": "3021", "jobId": "uuid", "clips": 6 }

clips: 0 = processou mas sem cortes bons (ainda sucesso). Integrações novas devem usar os eventos v2 — o v1 se mantém por compatibilidade.

Verificação HMAC (Polf-Signature)

Todo webhook (v1 e v2) leva o header Polf-Signature: t=<unix>,v1=<hmac_hex>, onde v1 = HMAC-SHA256(webhook_secret, "<t>.<corpo_cru>") — formato Stripe-style. Verifique SEMPRE: assinatura sobre o corpo cru, comparação em tempo constante e janela anti-replay de 5 minutos.

Node.js / TypeScript
import crypto from "node:crypto";

// IMPORTANTE: a assinatura é do corpo CRU. No Express, use
// express.raw({ type: "application/json" }) NESTA rota (não o json parser).
export function verifyPolfSignature(
  rawBody: string,
  signatureHeader: string, // header "Polf-Signature": t=1751464051,v1=5257a86…
  secret: string,          // whsec_… (entregue UMA vez junto com a sua API key)
  toleranceS = 300
): boolean {
  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; // anti-replay
  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); // tempo-constante
}

// app.post("/webhooks/polf", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyPolfSignature(req.body.toString("utf8"),
//       req.header("Polf-Signature") ?? "", process.env.POLF_WEBHOOK_SECRET!)) {
//     return res.status(400).end();
//   }
//   const evt = JSON.parse(req.body.toString("utf8"));
//   // deduplique por evt.eventId (entrega at-least-once) e responda 2xx RÁPIDO
//   res.status(200).end();
// });
Python
import hmac, hashlib, time

def verify_polf_signature(raw_body: bytes, header: str, secret: str, tol: int = 300) -> bool:
    """header = request.headers["Polf-Signature"]  (formato: t=<unix>,v1=<hmac_hex>)
    secret = whsec_… (entregue UMA vez junto com a sua API key)."""
    p = dict(kv.split("=", 1) for kv in header.split(","))
    if abs(time.time() - int(p["t"])) > tol:      # anti-replay (janela de 5 min)
        return False
    expected = hmac.new(secret.encode(),
                        p["t"].encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, p["v1"])  # comparação tempo-constante

# FastAPI:
# @app.post("/webhooks/polf")
# async def hook(request: Request):
#     raw = await request.body()                   # corpo CRU (antes de parsear)
#     if not verify_polf_signature(raw, request.headers["Polf-Signature"], SECRET):
#         raise HTTPException(400)
#     evt = json.loads(raw)                        # dedup por evt["eventId"]
#     return {"ok": True}                          # 2xx rápido; processe async

Sandbox exclusivo Polf

Teste o fluxo INTEIRO — create, webhooks, polling, download e os erros — no seu CI, sem gastar crédito, GPU ou download. Basta usar uma fonte mágica polf-test:// no lugar da videoUrl (padrão test-cards do Stripe, transposto pro clipping):

Fonte mágicaO que simula
polf-test://successSucesso em ~5s: 2 clips fake ("[SANDBOX] …") com mp4/download REAIS (asset fixo 3s 9:16) e webhooks na ordem de produção: project.selected → clip.rendered ×2 → project.completed.
polf-test://fail-sourceFalha no download em ~2s: project.failed com { "errorCode": "SOURCE_UNAVAILABLE", "retriable": false }.
polf-test://fail-renderSeleção ok → queima falha: project.selected clip.failedproject.failed com { "errorCode": "RENDER_FAILED", "retriable": true }.
polf-test://slowFica processing (stage render) por ~10 min SEM evento terminal — teste o SEU teto anti-limbo/timeout — e então completa como o success.
sandbox no CI
# roda no seu CI sem gastar crédito, GPU ou download
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": "polf-test://success", "webhookUrl": "https://seu-app.com/webhooks/polf"}'

# em ~5s: project.selected → clip.rendered ×2 → project.completed
# os clips fake vêm marcados "[SANDBOX] …" e o mp4/downloadUrl funcionam DE VERDADE
# (asset fixo de 3s 9:16) — dá pra testar o download durável de ponta a ponta.
# Todos os payloads de sandbox levam "sandbox": true no data.
  • Custo zero de verdade: não reserva nem debita crédito, não usa GPU/download.
  • Todos os payloads de sandbox levam "sandbox": true no data — filtre nos seus handlers se precisar.
  • Sem idempotencyKey, cada chamada de sandbox cria um job NOVO (o CI pode repetir a mesma URL); com a chave explícita, a idempotência vale normal.
  • Contrato público: os nomes polf-test://success|fail-source|fail-render|slow nunca mudam — só adicionamos modos novos.

Catálogo de erros

Dois níveis, os dois estáveis: o code numérico do envelope (compat-Vizard) e o errorCode string do objeto error v2 (em query, list e webhooks). retriable: true = falha transitória (timeout/instabilidade de provedor) — vale reprocessar; false = permanente (arrume a causa antes). Pra reprocessar um projeto que falhou, mande um novo create com um idempotencyKey NOVO (a chave antiga devolve o projeto falho).

errorCode (v2 — string estável)

errorCodeQuando aconteceretriable típico
SOURCE_UNAVAILABLELink inválido/privado/removido/restrito, arquivo corrompido, fonte expirada ou download falhou.false (link ruim) · true quando o serviço de download caiu temporariamente
NO_CREDITSCréditos insuficientes pra processar.false — recarregue e reenvie
LANGUAGE_UNSUPPORTEDO vídeo não é falado em português (por enquanto só PT-BR).false
RENDER_FAILEDA queima (render/legendas) falhou.geralmente true (timeout/5xx)
INTERNALFalha interna do pipeline (transcrição, seleção, interrupção).varia — confie no retriable do payload
NO_CLIPSReservado. Hoje “sem cortes bons” NÃO é falha: vem como status: done com clipsTotal: 0 (code 2000, videos: []).
PUBLISH_FAILEDSó em posts sociais: a rede recusou a publicação (o detalhe vem no error.message do target).repassado da rede (retriable honesto por alvo)
RATE_PROTECTEDSó em posts sociais (HTTP 429): a trava segura da conta foi atingida (teto/dia, espaçamento ou rampa de conta nova) — vem com nextSlot. Veja Travas de postagem.true — re-tente no nextSlot ou use scheduledAt: "auto"

code do envelope (compat-Vizard)

codeSignificado
2000Sucesso (create aceito / projeto pronto).
1000Processando (continue o polling).
4002Falha ao enfileirar / clipagem falhou — reprocessável.
4003Integração temporariamente indisponível (kill-switch) — re-tente depois.
4004 / 4005Formato não suportado / arquivo corrompido.
4006Parâmetro inválido, vídeo longo demais ou recurso não encontrado.
4007Créditos insuficientes.
4008 / 4009Download da fonte falhou / URL inválida.
4010Idioma não suportado.
4099Falha interna do pipeline.

Nota de transporte: os endpoints de clipping respondem HTTP 200 com o code no corpo (padrão Vizard — parseie response.code). Os endpoints novos (download, social) usam status HTTP semânticos (302/401/402/404/409/410/429/503).

Rate limits

Limites por chave (não por IP), janela de 60s. Toda resposta autenticada de /api/v1/* leva os headers IETF RateLimit-Limit, RateLimit-Remaining e RateLimit-Reset (segundos até liberar 1 slot) — programe o seu client por eles em vez de chutar.

RotasTeto
POST /api/v1/project/create20 req/min
POSTs caros (/export, /publish…)20 req/min
Leitura B2B (query, projects, download, posts…)120 req/min (polling contínuo de 2 req/s não encosta)

Estourou → HTTP 429 com Retry-After (em segundos). Respeite o header e re-tente — não martele.

exemplo de resposta 429
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 23
Retry-After: 23

{"detail": "rate limit excedido, tente em instantes"}

Retenção de arquivos

  • MP4s e fontes: 30 dias. Depois disso o downloadUrl responde 410 Gone — se precisar de novo, reprocesse o projeto. Se o seu fluxo guarda os vídeos, baixe e armazene do seu lado dentro da janela.
  • URLs presigned (streamUrl/videoUrl/thumbnailUrl): ~1 hora. São pra tocar/exibir na hora. Nunca as persista — persista o downloadUrl (durável) e o clipId.
  • Metadados (projetos, clips, notas, transcrição): permanecem consultáveis pelo query/list normalmente.
  • Métricas sociais: espelhadas 1×/dia; retenção de 30 dias do provedor.

OpenAPI & llms.txt

  • openapi.json — spec OpenAPI 3.1 das rotas públicas, gerada do código real (gere seus tipos com openapi-typescript ou similar).
  • llms.txt / llms-full.txt — esta documentação em formato pra agentes/LLMs (padrão llmstxt.org). Aponte seu agente de código pra cá e ele integra sozinho.

SDK TypeScript oficial

@polf_ai/sdk — cliente tipado (ESM+CJS, Node ≥ 18, zero dependências) com polling que respeita RateLimit-*/Retry-After e verificação de webhook estilo Stripe.

instalar
npm install @polf_ai/sdk
create → wait → download
import Polf from "@polf_ai/sdk";

const polf = new Polf({ apiKey: process.env.POLF_API_KEY! });

const { projectId } = await polf.projects.create({
  videoUrl: "https://www.youtube.com/watch?v=XXXX",   // ou polf-test://success em CI
  externalUserId: "user_42",
});
const project = await polf.projects.waitUntilDone(projectId);
for (const clip of project.videos) {
  console.log(clip.title, clip.downloadUrl);          // URL DURÁVEL (302 → fresca)
}
verificar webhook (corpo CRU + header Polf-Signature)
const event = Polf.webhooks.constructEvent(rawBody, signatureHeader, secret);
// evento TIPADO: narrow por event.event (project.selected | clip.rendered |
// project.completed | project.failed | clip.failed | post.published | post.failed)
if (event.event === "clip.rendered") console.log(event.data.clip.downloadUrl);
// assinatura inválida/replay → lança WebhookVerificationError (responda 400)

Feedback ou campo faltando? Fala com a gente — o contrato evolui só de forma aditiva.