API v2

Documentação para desenvolvedores

API REST para levar produtos, enriquecimento com IA, posts de blog e páginas de categoria da Ozygen para dentro da sua plataforma. Endereço base https://platform.ozygen.app/v2.

Criar chave de API no painel

Referência completa da API v2, escrita para ser lida por um agente/LLM que vai integrar com ela. Gerada a partir do código-fonte (app/api/v2/**, lib/api/v2/**) — é o contrato real, não um resumo.

Este documento é o que está publicado em https://ozygen.app/developers. A referência antiga da v1 (GET|POST /api/v1/products) continua em https://admin.ozygen.app/developers/v1, mas não descreve nenhum endpoint v2.


1. Endereço base

Host Formato do caminho Observação
https://platform.ozygen.app /v2/<recurso> Host recomendado. Superfície de máquina: qualquer caminho fora de /v1 e /v2 responde 404 JSON.
https://admin.ozygen.app /api/v2/<recurso> Mesmo deploy, mesmo comportamento. Serve também o painel.

platform.ozygen.app/v2/x é reescrito internamente para /api/v2/x — os dois hosts atendem as mesmas rotas. Neste documento os caminhos são escritos como /v2/... (host platform).

Todos os exemplos assumem:

B="https://platform.ozygen.app"
K="oz_sk_live_..."     # sua chave secreta

2. Autenticação

Authorization: Bearer oz_sk_live_<64 hex>

Sem esse header a resposta é 401 UNAUTHENTICATED com a mensagem Missing API key. Send it as: Authorization: Bearer oz_sk_live_….

Tipos de chave

Prefixo Tipo Pode
oz_sk_live_… secret Tudo que os escopos permitirem. Uso servidor-a-servidor.
oz_pk_live_… publishable Só leitura, e só em rotas marcadas allowPublishable. Exige lista de origens (CORS). Rate limit é ¼ do da conta.
oz_sk_test_… / oz_pk_test_… test Rejeitadas hoje com 403 FORBIDDEN — Sandbox keys are not available yet. Use a live key. O sandbox ainda não existe.

As chaves são criadas em https://admin.ozygen.app/integrations (exige papel ADMIN na organização) e o valor bruto aparece uma única vez, na criação. Restrições opcionais por chave: allowedOrigins (CORS) e allowedIps — violá-las dá 403 ORIGIN_NOT_ALLOWED / 403 IP_NOT_ALLOWED.

Escopos

Uma chave de API não carrega papel de organização; os escopos são todo o modelo de autorização.

products:read     products:write
enrichment:write
blog:read         blog:write        blog:generate
content:read      content:write     content:generate     (conteúdo de categoria)
brand:read        brand:write                            (manual de marca + estilos de escrita)
seo:read          seo:write         seo:run
jobs:read
credits:read
webhooks:manage

Formas aceitas na chave: escopo concreto (products:read), curinga por recurso (products:*) ou global (*). O curinga nunca é implícito — só existe se alguém criou a chave assim.

⚠️ Pegadinha frequente: products:write não autoriza enriquecimento. Enriquecer exige enrichment:write. Confira o que sua chave realmente tem em GET /v2/me → effectiveScopes.

Escopo faltando → 403 INSUFFICIENT_SCOPE, com details: { required: [...], granted: [...] }.

Separação que importa no conteúdo: blog:write / content:write criam e editam (grátis); blog:generate / content:generate mandam a IA escrever (gasta crédito). Uma chave pode ter um sem o outro — por exemplo, um CMS que só publica texto próprio não precisa de :generate.

Escopos declarados que ainda não têm endpoint: brand:write e seo:write. Existem no vocabulário, mas nenhuma rota v2 os consome hoje.


3. Formato das respostas

Toda resposta de sucesso tem o mesmo envelope:

{
  "data": { /* ou [ ... ] em listas */ },
  "meta": {
    "requestId": "6415cf00-1036-480a-b7e8-7929980e1be4",
    "pagination": { "limit": 50, "offset": 0, "total": 162, "hasMore": true, "nextCursor": null },
    "credits":    { "charged": 1, "remaining": 98 },   // só em operações que cobram
    "cached":     true,                                 // só quando reaproveitou resultado
    "dryRun":     true                                  // só em ?dryRun=true
  }
}

Erro:

{
  "error": {
    "code": "INSUFFICIENT_SCOPE",
    "message": "This API key lacks the required scope.",
    "details": { "required": ["enrichment:write"], "granted": ["products:read", "products:write"] },
    "requestId": "…",
    "docsUrl": "https://ozygen.app/developers#insufficient-scope"
  }
}

O status HTTP sempre corresponde ao resultado. Nunca há falha embrulhada em 200.

Códigos de status usados

Status Quando
200 Leitura, atualização, ou trabalho reaproveitado de cache
201 Recurso criado (POST /v2/products, /v2/blog-posts, /v2/category-contents, /v2/webhook-endpoints)
202 Trabalho assíncrono aceito — o corpo é um Job
204 Sem conteúdo
400 Validação, JSON inválido, Idempotency-Key faltando onde é obrigatório
401 Chave ausente, inválida, expirada ou revogada
402 Créditos insuficientes
403 Escopo, origem, IP, ou chave publishable em rota que exige secret
404 Não existe ou não é da sua organização (indistinguíveis, de propósito)
409 Conflito: já rodando, idempotência reutilizada
413 Corpo maior que 1 MB
429 Rate limit
5xx Erro interno / upstream

Códigos de erro

O docsUrl de cada erro aponta para a linha correspondente desta tabela.

Código Status Significado
VALIDATION_ERROR 400 Corpo/query inválidos, id referenciado que não existe, ou Idempotency-Key faltando. details[] traz path, code e message por campo
INVALID_CSV 400 Arquivo CSV ilegível
NO_VALID_ROWS 400 Nenhuma linha válida no arquivo
BAD_VERSION 400 Versão de histórico inválida
CONTENT_TOO_SHORT 400 Texto curto demais para a operação
NO_CONTENT 400 O recurso não tem texto para a operação
NO_KEYWORDS 400 Projeto de rank tracking sem keywords
NO_CRAWL 400 A operação depende de um crawl concluído
UNAUTHENTICATED 401 Falta Authorization: Bearer
INVALID_API_KEY 401 Chave não reconhecida
EXPIRED_API_KEY 401 Chave expirada
REVOKED_API_KEY 401 Chave revogada
INSUFFICIENT_CREDITS 402 Sem saldo. details: { required, available, deficit, topUpUrl }
INSUFFICIENT_SCOPE 403 Falta escopo. details: { required, granted } (§2)
ORIGIN_NOT_ALLOWED 403 Origem fora da lista da chave publishable
IP_NOT_ALLOWED 403 IP fora da lista da chave
PUBLISHABLE_KEY_FORBIDDEN 403 A rota exige chave secreta
FORBIDDEN 403 Proibido por outra razão (ex.: chave _test_)
NOT_FOUND 404 Não existe ou é de outra organização
METHOD_NOT_ALLOWED 405 Método não suportado no caminho
DUPLICATE 409 Já existe (produto com o mesmo sourceId, categoria com o mesmo nome, slug de post). details traz o id existente quando possível
DUPLICATE_URL 409 URL já cadastrada
ALREADY_RUNNING 409 Já há execução em andamento; nada foi cobrado
HAS_CHILDREN 409 O recurso tem filhos que seriam afetados
WOULD_LOSE_DATA 409 A operação apagaria dados irrecuperáveis
IDEMPOTENCY_KEY_REUSED 409 Mesma Idempotency-Key com corpo diferente (§5)
IDEMPOTENCY_IN_PROGRESS 409 Requisição com a mesma chave ainda em andamento
GONE 410 O recurso não está mais disponível
PAYLOAD_TOO_LARGE 413 Corpo acima de 1 MB
UNPROCESSABLE 422 Entrada válida, mas a operação não se aplica ao estado atual
NO_VERIFIED_NEIGHBOURS 422 Sem páginas vizinhas verificadas para a operação
NOT_CONNECTED 422 Integração necessária não conectada
MISSING_CREDENTIALS 422 Integração sem credenciais
RATE_LIMITED 429 Limite estourado; veja Retry-After (§6)
INTERNAL_ERROR 500 Erro interno — cite o requestId
DISPATCH_ERROR 5xx O trabalho não pôde ser enfileirado; tente de novo
UPSTREAM_ERROR 502 Falha num serviço externo
EVALUATION_FAILED 5xx Avaliação de qualidade falhou
CONNECTION_FAILED 5xx Falha ao conectar a um serviço externo
SERVICE_UNAVAILABLE 503 Serviço temporariamente indisponível

Paginação

Listas usam ?limit= (1–100, padrão 50) e ?offset= (padrão 0). meta.pagination.nextCursor existe sempre e hoje é sempre null — o campo está reservado para paginação por cursor no futuro; não dependa dele ainda, use offset.

Três listas são não paginadas por natureza (retornam array direto em data): /v2/writing-formats, /v2/rank-tracking/projects e /v2/webhook-endpoints.


4. Headers

Que você envia

Header Obrigatório Para quê
Authorization: Bearer <key> sempre autenticação
Content-Type: application/json em POST/PATCH com corpo —
Idempotency-Key: <uuid> obrigatório nas operações que gastam crédito evita cobrança dupla em retry
X-Request-Id: <id> não é ecoado na resposta se casar com ^[A-Za-z0-9_-]{1,64}$

Que você recebe

Header Significado
X-Request-Id correlação com o log do servidor — cite este id em qualquer suporte
X-Ozygen-Api-Version sempre 2
X-RateLimit-Limit / -Remaining / -Reset contadores exatos (Reset é epoch em segundos)
Retry-After em 429, e em GET /v2/jobs/{id} de job não terminal (valor 3 — cadência sugerida de polling)
Idempotent-Replay: true esta resposta é a repetição de uma anterior; nada foi executado de novo

5. Idempotência

Vale para todo método com corpo (POST, PATCH, PUT, DELETE).

  • Envie Idempotency-Key: <string única, ≤255 chars> (um UUID por operação lógica).
  • A chave é registrada antes do handler rodar, então a cobrança de crédito acontece dentro da janela.
  • Repetir a mesma chave com o mesmo corpo devolve a resposta armazenada, com Idempotent-Replay: true. O registro vale por 24 h; depois disso a chave é reciclada e a operação roda de novo.
  • A comparação de corpo é por hash canônico — ordem das chaves do JSON não importa.
  • Repetir a mesma chave com corpo diferente → 409 IDEMPOTENCY_KEY_REUSED.
  • Requisição ainda em andamento com a mesma chave → 409 IDEMPOTENCY_IN_PROGRESS.
  • Respostas 2xx e 4xx são guardadas para replay; 5xx libera a reserva para você poder tentar de novo de verdade.

Obrigatório (sem o header → 400 VALIDATION_ERROR) em: POST /v2/enrichment/runs, POST /v2/blog-posts/{id}/generate, POST /v2/category-contents/{id}/generate, POST /v2/backlinks/snapshots, POST /v2/rank-tracking/projects/{projectId}/runs.


6. Rate limits

Janela fixa, contada no banco no caminho da requisição (os contadores nos headers são exatos, não estimativa).

Três baldes por minuto, por chave:

Plano default poll heavy por dia
free 30 120 2 2.000
starter 60 300 5 20.000
pro 180 600 15 100.000
business 600 1.800 40 500.000
enterprise 2.000 6.000 120 5.000.000
  • poll — /v2/me, /v2/jobs, /v2/jobs/{id}, /v2/credits. Não consome a cota diária: você pode acompanhar um job sem gastar o orçamento do trabalho real.
  • heavy — toda operação que dispara trabalho pesado (enriquecimento, geração, rank run, backlinks, auditoria, crawl). É este o limite que dita a velocidade de um lote.
  • default — o resto.

Chave publishable recebe ¼ do limite do plano. Limites são resolvidos do plano atual da organização a cada requisição (não ficam congelados na chave).

Ao estourar: 429 RATE_LIMITED + Retry-After em segundos.


7. Créditos

Operação Custo Observação
product.enrich 1 por produto —
blog.generate 3 por post —
category_content.generate 2 por página —
rank.run keywords × (2 se BOTH, senão 1) cresce com o projeto
backlinks.snapshot 50 domínio / 20 página force: true dobra; cache de 24 h torna grátis
seo_audit.run 0 grátis
site_crawl.run 0 grátis
product.import 0 grátis

Sem saldo: 402 INSUFFICIENT_CREDITS com details: { required, available, deficit, topUpUrl }.

O crédito é reservado antes do trabalho começar e devolvido automaticamente se o job falhar — o estorno aparece em job.error.creditsRefunded e como valor negativo em /v2/credits/usage.

?dryRun=true — cotação sem efeito colateral

Disponível em /v2/enrichment/runs, /v2/blog-posts/{id}/generate, /v2/category-contents/{id}/generate, /v2/backlinks/snapshots e /v2/rank-tracking/projects/{id}/runs. Responde 200 com meta.dryRun: true e nada é executado nem cobrado:

{
  "data": {
    "operation": "product.enrich",
    "cost": 1,
    "available": 99,
    "sufficient": true,
    "wouldUseCache": false,
    "cacheExpiresAt": null
  },
  "meta": { "requestId": "…", "dryRun": true }
}

Em rank.run o corpo traz também totalKeywords e device.


8. Jobs — o modelo de trabalho assíncrono

Toda operação pesada responde 202 com um Job. Não existe endpoint que devolva conteúdo gerado de forma síncrona: você inicia, recebe o job, e acompanha.

O recurso Job

{
  "id": "job_c1x2y3…",
  "kind": "product_enrichment",
  "status": "running",
  "progress": { "percent": null, "current": null, "total": null, "stage": null },
  "result": null,
  "error": null,
  "credits": { "reserved": 1, "refunded": 0 },
  "createdVia": "api",
  "sandbox": false,
  "startedAt": "2026-08-24T19:18:24.000Z",
  "finishedAt": null,
  "createdAt": "2026-08-24T19:18:23.000Z",
  "updatedAt": "2026-08-24T19:18:24.000Z"
}

Quando termina com sucesso:

"result": {
  "resourceType": "product",
  "resourceId": "cmt7lxmys000dkc3elfojuwaa",
  "href": "/v2/products/cmt7lxmys000dkc3elfojuwaa",
  "summary": { }
}

Quando falha:

"error": { "code": "…", "message": "…", "retryable": true, "creditsRefunded": 1 }

progress.percent é null de propósito quando não há contador real — só importações e rank runs têm progresso verdadeiro. Não invente barra de progresso a partir de null.

Estados

queued → running → succeeded | failed | canceled (os três últimos são terminais).

Tipos (kind)

product_enrichment, product_import, blog_generation, category_content_generation, seo_audit, site_crawl, rank_run, backlinks_snapshot, ai_visibility_run, prompt_run, link_targets_sitemap_import, product_export.

Como acompanhar

curl -s -H "Authorization: Bearer $K" "$B/v2/jobs/job_abc123"
  • Enquanto não for terminal, a resposta traz Retry-After: 3 — siga essa cadência; o balde poll comporta e não consome cota diária.
  • O GET reconcilia o job contra a tabela de origem antes de responder, então o status está correto mesmo que um worker não tenha reportado a transição.
  • GET /v2/jobs lista o histórico: filtros status, kind, resourceId, createdAfter, createdBefore (ISO-8601), limit, offset.
  • ?resourceId=<id do produto> recupera o job de um recurso quando você perdeu o id do job.

GET /v2/jobs retornando {"data": [], ...} significa apenas que esta organização nunca iniciou trabalho assíncrono — não é erro nem falta de permissão.


9. Referência de endpoints

Legenda: pk = aceita chave publishable · Idem = Idempotency-Key obrigatório · heavy = balde pesado.

Identidade e conta

Método Caminho Escopo Notas
GET /v2/me qualquer chave válida pk · poll
GET /v2/credits credits:read poll
GET /v2/credits/usage credits:read extrato paginado

Produtos

Método Caminho Escopo Notas
GET /v2/products products:read pk
POST /v2/products products:write Idem · sem crédito
GET /v2/products/{id} products:read pk
PATCH /v2/products/{id} products:write —
DELETE /v2/products/{id} products:write arquiva (status: ARCHIVED), não apaga

Enriquecimento

Método Caminho Escopo Notas
POST /v2/enrichment/runs enrichment:write heavy · Idem · 1 crédito

Blog

Método Caminho Escopo Notas
GET /v2/blog-posts blog:read pk
POST /v2/blog-posts blog:write cria a pauta (rascunho) · sem crédito · §10.9
GET /v2/blog-posts/{id} blog:read pk · inclui content
PATCH /v2/blog-posts/{id} blog:write edita texto/SEO, publica, arquiva · §10.10
DELETE /v2/blog-posts/{id} blog:write arquiva (status: ARCHIVED), não apaga
POST /v2/blog-posts/{id}/generate blog:generate heavy · Idem · 3 créditos

Conteúdo de categoria

Método Caminho Escopo Notas
GET /v2/category-contents content:read pk
POST /v2/category-contents content:write registra a categoria · sem crédito · §10.11
GET /v2/category-contents/{id} content:read pk · inclui body, faq
PATCH /v2/category-contents/{id} content:write edita texto/FAQ/SEO/URL, publica · §10.12
POST /v2/category-contents/{id}/generate content:generate heavy · Idem · 2 créditos

Marca

Método Caminho Escopo Notas
GET /v2/brand-manual brand:read pk · singleton (sem id, sem lista)
GET /v2/writing-formats brand:read pk · array direto, sem paginação

SEO

Método Caminho Escopo Notas
GET /v2/seo-audits seo:read pk
POST /v2/seo-audits seo:run heavy · grátis · sem Idem
GET /v2/seo-audits/{id} seo:read pk · inclui lista de issues
GET /v2/backlinks/snapshots seo:read pk · filtro ?target=
POST /v2/backlinks/snapshots seo:run heavy · Idem · 20–100 créditos
GET /v2/backlinks/snapshots/{id} seo:read pk · inclui âncoras e países
GET /v2/rank-tracking/projects seo:read pk · array direto
GET /v2/rank-tracking/projects/{projectId} seo:read pk · traz keywords com posição atual
GET /v2/rank-tracking/projects/{projectId}/runs seo:read pk
POST /v2/rank-tracking/projects/{projectId}/runs seo:run heavy · Idem
GET /v2/rank-tracking/projects/{projectId}/runs/{runId} seo:read pk · inclui snapshots por keyword
GET /v2/internal-links/crawls seo:read pk
POST /v2/internal-links/crawls seo:run heavy · grátis · sem Idem
GET /v2/internal-links/crawls/{id} seo:read pk
GET /v2/internal-links/opportunities seo:read pk

Jobs

Método Caminho Escopo Notas
GET /v2/jobs jobs:read pk · poll
GET /v2/jobs/{id} jobs:read pk · poll

Webhooks

Método Caminho Escopo
GET / POST /v2/webhook-endpoints webhooks:manage
GET / PATCH / DELETE /v2/webhook-endpoints/{id} webhooks:manage
POST /v2/webhook-endpoints/{id}/test webhooks:manage
POST /v2/webhook-endpoints/{id}/rotate-secret webhooks:manage
GET /v2/webhook-endpoints/{id}/deliveries webhooks:manage

10. Detalhe dos recursos principais

10.1 GET /v2/me

Barato (balde poll, zero crédito) — use na inicialização e em health check.

{
  "data": {
    "apiKey": {
      "id": "…",
      "type": "secret",
      "environment": "live",
      "scopes": ["products:*", "enrichment:write"],
      "effectiveScopes": ["products:read", "products:write", "enrichment:write"]
    },
    "organization": { "id": "f3b0…", "name": "BoaBreja", "domain": "www.boabreja.com.br", "planTier": "enterprise" },
    "credits": { "available": 99, "monthly": 100, "addon": 0 },
    "rateLimits": { "perMinute": 2000, "perDay": 5000000 }
  }
}

scopes é o que está gravado na chave; effectiveScopes já expande curingas — decida por effectiveScopes, não implemente expansão de * do seu lado.

10.2 GET /v2/products

Filtros (todos opcionais):

Parâmetro Valores
search texto livre
status DRAFT | ACTIVE | ARCHIVED
source VTEX | SHOPIFY | WAKE | XML | SITEMAP | MANUAL
enriched "true" (só enriquecidos) | "false" (nunca enriquecidos)
qualityMin / qualityMax inteiro 0–100
category texto
enrichedFrom / enrichedTo yyyy-mm-dd
sortBy createdAt | updatedAt | title | qualityScore | lastEnrichedAt (padrão createdAt)
sortOrder asc | desc (padrão desc)
limit / offset 1–100 / ≥0

Para montar uma fila de enriquecimento: ?enriched=false&limit=100.

Formato do produto:

{
  "id": "cmt7lxmys000dkc3elfojuwaa",
  "sku": null, "gtin": null,
  "sourceId": "catalog-cmmnf4u96001m3esktg62pcyi", "source": "XML",
  "status": "ACTIVE",
  "url": "https://www.boabreja.com.br/cervejas/amstel/amstel-lager-350ml",
  "slug": null,
  "title": "Amstel - Amstel Lager - 350ml",
  "metaTitle": "Cerveja Amstel 350ml Lager 4,5% ABV IBU 21 | BoaBreja",
  "description": "<h2>…</h2>",
  "shortDescription": "Amstel Lager 350ml holandesa…",
  "brand": "Amstel",
  "category": "Alimentos e Bebidas > Bebidas > Cervejas > Lager",
  "price": null, "salePrice": null, "currency": "BRL", "availability": "in_stock",
  "images": ["https://www.boabreja.com.br/imagens/…webp"],
  "quality": { "score": 76, "grade": "C" },
  "enrichment": { "enriched": true, "lastEnrichedAt": "2026-08-24T19:21:42.471Z" },
  "tags": [],
  "createdAt": "…", "updatedAt": "…"
}

Semântica dos campos de texto: description é o corpo (HTML) e shortDescription é a meta description. O enriquecimento sobrescreve os dois, mais metaTitle.

images sai como array de verdade (é Text com JSON no banco; o presenter converte).

Assimetria conhecida: tags vem preenchido em GET /v2/products (lista), mas o detalhe GET /v2/products/{id} sempre devolve tags: [] — a consulta do detalhe não carrega a relação. Se você precisa das tags de um produto, leia-as pela lista (?search= ou filtro equivalente).

10.3 POST /v2/products — criar um produto

Cria um produto avulso, sem passar por importador. Não gasta crédito (criar ≠ enriquecer).

curl -X POST "$B/v2/products" \
  -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "title": "Amstel Lager 350ml",
        "sku": "AMS-350",
        "url": "https://www.boabreja.com.br/cervejas/amstel/amstel-lager-350ml",
        "brand": "Amstel",
        "category": "Alimentos e Bebidas > Bebidas > Cervejas > Lager",
        "price": 4.49,
        "images": ["https://www.boabreja.com.br/imagens/amstel-350.webp"]
      }'

Resposta 201 com o produto no mesmo formato do GET (seção 10.2).

Campo Obrigatório Notas
title sim 1–500 chars. Imutável depois — o PATCH descarta title. É sua única chance de acertar.
sku não vira o sourceId padrão (veja abaixo)
sourceId não seu identificador. Padrão: sku; sem sku, um manual-<uuid>
metaTitle não ≤200 chars
description / shortDescription não corpo HTML / meta description
url, slug, gtin, brand, category, attributes, availability não texto
images não array de URLs (≤20), não string — diferente do PATCH
price / salePrice não número positivo
currency não padrão "BRL"
status não DRAFT (padrão) | ACTIVE | ARCHIVED

Dois campos do modelo não são aceitos aqui:

  • source é sempre MANUAL. A chave única é (organização, source, sourceId); deixar o chamador declarar source: "VTEX" permitiria colidir com — ou encobrir — linhas que o importador da VTEX é dono.
  • organizationId vem da chave de API, nunca do corpo.

Duplicidade: repetir o mesmo sourceId (ou o mesmo sku, quando é ele que vira o sourceId) devolve 409 DUPLICATE com o id do que já existe — é o caminho para você seguir com um PATCH:

{ "error": { "code": "DUPLICATE",
  "details": { "sourceId": "AMS-350", "existingProductId": "cmt7lxmys000dkc3elfojuwaa" } } }

Idempotency-Key não é obrigatório aqui (não há crédito em jogo), mas mande mesmo assim: um timeout seguido de retry sem a chave cria o produto duas vezes se você não tiver mandado sku nem sourceId.

Para enriquecer o produto recém-criado, use POST /v2/enrichment/runs com o id devolvido — lembrando que isso exige enrichment:write, não products:write.

10.4 PATCH /v2/products/{id}

Campos aceitos: metaTitle, description, shortDescription, attributes, category, brand, images, url, slug, sku, gtin, price, salePrice, currency, availability, status, isVisible, isFavorite.

title é imutável depois da importação. Mandar title não dá erro — a chave é simplesmente descartada. Para mudar o título de SEO, use metaTitle.

images e attributes aqui são strings (JSON serializado), não arrays — assimetria com o GET, que devolve images como array.

10.5 POST /v2/enrichment/runs — enriquecer um produto

Este é o endpoint de enriquecimento. Um produto por chamada; não existe rota de lote na v2.

curl -X POST "$B/v2/enrichment/runs" \
  -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "productId": "cmt7lxmys000dkc3elfojuwaa" }'

Corpo:

Campo Tipo Obrigatório
productId string sim
writingStyleId string não — omitido, usa o estilo de escrita padrão da organização

Resposta 202 com o Job e meta.credits:

{
  "data": { "id": "job_…", "kind": "product_enrichment", "status": "queued", "credits": { "reserved": 1, "refunded": 0 }, "…": "…" },
  "meta": { "requestId": "…", "credits": { "charged": 1, "remaining": 98 } }
}

Pré-requisitos do lado da conta (senão o job falha e o crédito é estornado):

  • existe pelo menos um estilo de escrita com targetAudience preenchido — é entrada não-negociável do motor. Verifique em GET /v2/writing-formats;
  • o produto tem alguma description de base (o motor usa como ponto de partida).

Um enriquecimento leva tipicamente 2 a 4 minutos.

10.6 GET /v2/writing-formats

Array direto em data, sem paginação. É de onde sai o writingStyleId/writingFormatId usado nas gerações.

{
  "id": "…", "name": "Breja1", "isActive": true,
  "voice": { "tone": "casual", "voice": "first-person", "vocabulary": "simple",
             "sentenceLength": "long", "targetAudience": "Adultos que…", "authorPersona": "…" },
  "content": { "style": "narrative", "benefitsFormat": "benefits", "targetWordCount": null },
  "branding": { "includeBrandInTitle": true, "brandName": "BoaBreja" },
  "salesArguments": ["…"], "guidelines": ["…"], "examples": ["…"],
  "aiModel": "claude-opus-4-8", "autoInternalLinks": false,
  "createdAt": "…", "updatedAt": "…"
}

10.7 POST /v2/backlinks/snapshots — o único com cache

Corpo: { "target": "exemplo.com.br", "targetType": "DOMAIN" | "PAGE", "force": false }.

O status HTTP é o discriminador:

  • 200 + meta.cached: true → havia snapshot com menos de 24 h; devolveu o resultado completo, cobrou 0.
  • 202 + Job → começou coleta nova, créditos reservados.

force: true ignora o cache e dobra o custo.

10.8 POST /v2/rank-tracking/projects/{projectId}/runs

Corpo: { "device": "DESKTOP" | "MOBILE" | "BOTH" } (opcional; padrão é o do projeto).

  • Projeto sem keywords → 400 NO_KEYWORDS.
  • Já existe run em andamento → 409 ALREADY_RUNNING com details.existingRunId, sem cobrar.
  • Custo cresce com o projeto — use ?dryRun=true antes.

Convenção de sinal: em delta, negativo significa que subiu (as posições contam para baixo).

10.9 POST /v2/blog-posts — criar um post

Cria a pauta: um post em DRAFT, sem corpo. Não gasta crédito e responde na hora (201). Escrever o texto é outro passo: ou a IA escreve (POST /v2/blog-posts/{id}/generate, 3 créditos), ou você manda o seu texto por PATCH (§10.10).

curl -X POST "$B/v2/blog-posts" \
  -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "title": "Como escolher a cerveja certa para o churrasco",
        "format": "guia_de_compra",
        "category": "Cervejas",
        "keywords": ["cerveja para churrasco", "melhor cerveja churrasco"],
        "writingFormatId": "cmw…",
        "productIds": ["cmt7lxmys000dkc3elfojuwaa"]
      }'
Campo Obrigatório Notas
title sim 1–500 chars. O slug sai dele (único na organização; ganha -1, -2… se repetir)
format não guia_de_compra (padrão) | comparativo | lista_roundup | tutorial | tendencias | faq
category não texto livre; sem keywords, a geração busca palavras-chave por esta categoria
keywords não array de strings (≤30, sem vírgula). É a entrada principal da geração
writingFormatId não estilo de escrita (GET /v2/writing-formats). Omitido → o padrão da organização
authorId não autor que assina o post
pillarId não pilar de conteúdo; sem keywords, a geração usa a keyword-alvo do pilar
primaryEntityId não entidade principal do post
productIds não até 50 produtos, vinculados como featured — a geração fala deles

Resposta 201 no formato do detalhe (GET /v2/blog-posts/{id}), com linkedProducts.

Todo id enviado é conferido: um authorId, writingFormatId, productIds[i] etc. que não existe (ou é de outra organização) → 400 VALIDATION_ERROR com details[].path apontando o campo e code: "not_found". Nada é criado nesse caso.

Mande Idempotency-Key: o slug desduplica com sufixo, então um retry sem a chave cria dois posts (como-escolher… e como-escolher…-1) em vez de dar 409.

tags no GET: antes da geração traz as keywords que você mandou; a geração sobrescreve esse campo com o FAQ do post.

10.10 PATCH /v2/blog-posts/{id} — editar, publicar, arquivar

Mesmo formato do GET — mande só o que muda:

{
  "title": "…",
  "excerpt": "…",
  "content": "<h2>…</h2><p>…</p>",
  "category": "…",
  "canonicalUrl": "https://www.loja.com.br/blog/meu-post",
  "featuredImage": "https://…/capa.webp",
  "writingFormatId": "cmw…",
  "authorId": "cma…",
  "seo": { "title": "…", "description": "…", "schemaMarkup": { "@type": "Article" } },
  "status": "PUBLISHED"
}
  • canonicalUrl, featuredImage, writingFormatId, authorId e seo.schemaMarkup aceitam null para limpar.
  • status: DRAFT | PUBLISHED | ARCHIVED. Publicar grava publishedAt e, se o post não tem canonicalUrl, deriva https://<domínio da organização>/blog/<slug>. Se você manda canonicalUrl junto com PUBLISHED, vale o seu.
  • Mudar title muda o slug. Se outro post já usa esse slug → 409 DUPLICATE.
  • Toda edição de texto guarda a versão anterior no histórico, como uma edição feita no painel. Mudar só o status não cria versão.
  • tags não é editável: a geração reescreve esse campo com o FAQ.
  • Corpo vazio ({}) → 400.

Resposta 200 com o post completo (formato do detalhe).

DELETE /v2/blog-posts/{id} arquiva (status: ARCHIVED), igual a produtos. Um post não é apagado pela API; para tirar do arquivo, PATCH com "status": "DRAFT".

10.11 POST /v2/category-contents — registrar uma categoria

Cria a página de categoria em DRAFT, normalmente vazia, para depois gerar (POST /v2/category-contents/{id}/generate, 2 créditos) ou preencher por PATCH. Grátis, 201.

curl -X POST "$B/v2/category-contents" \
  -H "Authorization: Bearer $K" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "category": "Alimentos e Bebidas > Bebidas > Cervejas > Lager",
        "title": "Cervejas Lager",
        "url": "https://www.boabreja.com.br/cervejas/lager"
      }'
Campo Obrigatório Notas
category sim o nome exatamente como está em product.category — é por ele que a geração acha os produtos da página. Único na organização
title não título da página (H1)
introduction / body não texto próprio, se você já tem (HTML)
url não endereço real da página na loja. Sem ele, a página fica num slug estimado até um crawl resolver a URL
parentId não id da categoria-mãe (outro category-content)

Mesma categoria duas vezes → 409 DUPLICATE com o id da existente:

{ "error": { "code": "DUPLICATE",
  "details": { "category": "…", "existingCategoryContentId": "cmx…" } } }

Resposta 201 no formato do detalhe (GET /v2/category-contents/{id}).

10.12 PATCH /v2/category-contents/{id} — editar e publicar

{
  "title": "…",
  "introduction": "<p>…</p>",
  "body": "<h2>…</h2>",
  "faq": [{ "question": "…", "answer": "…" }],
  "seo": { "title": "…", "description": "…", "schemaMarkup": { "@type": "FAQPage" } },
  "url": "https://www.loja.com.br/cervejas/lager",
  "slug": "lager",
  "parentId": "cmx…",
  "category": "…",
  "status": "PUBLISHED"
}
  • faq é um array de pares, como no GET (no banco é JSON). Trocar o FAQ não refaz o schemaMarkup sozinho: mande os dois juntos, ou gere de novo.
  • url, slug, parentId e seo.schemaMarkup aceitam null. Uma categoria não pode ser mãe dela mesma (400).
  • status: "PUBLISHED" grava publishedAt na primeira publicação.
  • Renomear category para um nome que outra página já usa → 409 DUPLICATE.
  • relatedLinks não é editável: sai da árvore de categorias e é refeito na geração.
  • Toda edição guarda a versão anterior no histórico (version sobe 1).

Não existe DELETE de categoria na v2: apagar uma mãe desliga as filhas da árvore. Para tirar do ar, use PATCH com "status": "ARCHIVED".


11. Webhooks

Alternativa ao polling. Endpoint precisa ser HTTPS público — endereços privados/loopback são recusados no cadastro e revalidados a cada entrega (um hostname pode ser reapontado depois).

Cadastro

curl -X POST "$B/v2/webhook-endpoints" \
  -H "Authorization: Bearer $K" -H "Content-Type: application/json" \
  -d '{ "url": "https://seu-app.com/hooks/ozygen", "description": "prod", "enabledEvents": ["*"] }'

201 — é a única resposta que contém secret (whsec_…). Guarde na hora; nenhuma leitura posterior devolve o segredo.

Eventos

Tipo Quando
job.succeeded job entrou em succeeded
job.failed job entrou em failed
webhook.test disparado por POST /v2/webhook-endpoints/{id}/test
credits.low declarado no vocabulário, mas ainda não emitido por nada

canceled não gera evento. Um estado terminal repetido (retry de worker, reconciliação) não dispara segunda vez.

Corpo entregue

O objeto em data.object é exatamente o recurso Job de GET /v2/jobs/{id} — um parser serve para polling e push. A única diferença: no webhook não há result.href.

{
  "id": "evt_…",
  "createdAt": "2026-08-24T19:22:03.113Z",
  "type": "job.succeeded",
  "apiVersion": "2",
  "organizationId": "f3b0…",
  "data": {
    "object": { "id": "job_…", "kind": "product_enrichment", "status": "succeeded", "…": "…" },
    "previousStatus": "running"
  }
}

Headers da entrega

Ozygen-Signature: t=1756061523,v1=<hmac-sha256 hex>
Ozygen-Event-Id: evt_…
Ozygen-Event-Type: job.succeeded
Ozygen-Delivery-Id: whd_…
Ozygen-Attempt: 1
Ozygen-Api-Version: 2
User-Agent: Ozygen-Webhooks/2.0

Verificação da assinatura

Padrão Stripe: HMAC-SHA256 sobre "{timestamp}.{corpo bruto}".

const crypto = require("crypto");

function verify(header, rawBody, secret, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.trim());
  const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false; // anti-replay

  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");

  return parts
    .filter((p) => p.startsWith("v1="))
    .map((p) => p.slice(3))
    .some((sig) => {
      const a = Buffer.from(sig, "hex");
      const b = Buffer.from(expected, "hex");
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    });
}

Três pontos que quebram integrações:

  1. Use o corpo bruto, antes de qualquer JSON.parse ou reserialização.
  2. Rejeite fora da janela de 300 s — é o que torna o anti-replay real.
  3. Durante uma rotação o header traz dois v1=; aceite qualquer um (o .some() acima já faz isso).

Entrega, retry e desativação

  • Timeout de 10 s por tentativa; 5 tentativas com backoff da fila. Redirect não é seguido.
  • Responda 2xx rápido e processe depois.
  • Após 20 falhas consecutivas o endpoint é desativado automaticamente. Reative com PATCH /v2/webhook-endpoints/{id} {"status":"ACTIVE"} — isso zera o contador.
  • GET /v2/webhook-endpoints/{id}/deliveries traz o histórico com o código HTTP e um trecho da resposta do seu servidor (filtros: status = PENDING|SUCCEEDED|FAILED|EXHAUSTED, eventType).
  • POST /v2/webhook-endpoints/{id}/rotate-secret com {"gracePeriodHours": 24} (0–72) assina com os dois segredos durante a janela, então dá para trocar sem cutover coordenado.

12. Receita: enriquecer um lote de produtos

B="https://platform.ozygen.app"; K="oz_sk_live_..."

# 1. Confirme escopo e saldo — enriquecer exige enrichment:write, não products:write.
curl -s -H "Authorization: Bearer $K" "$B/v2/me"

# 2. Garanta que existe estilo de escrita com público-alvo.
curl -s -H "Authorization: Bearer $K" "$B/v2/writing-formats"

# 3. Monte a fila: produtos nunca enriquecidos.
curl -s -H "Authorization: Bearer $K" "$B/v2/products?enriched=false&limit=100"

# 4. Cote antes de comprometer (opcional, grátis).
curl -s -X POST "$B/v2/enrichment/runs?dryRun=true" \
  -H "Authorization: Bearer $K" -H "Content-Type: application/json" \
  -d '{"productId":"<ID>"}'

# 5. Dispare — uma chamada por produto, Idempotency-Key único por produto.
curl -s -X POST "$B/v2/enrichment/runs" \
  -H "Authorization: Bearer $K" -H "Content-Type: application/json" \
  -H "Idempotency-Key: enrich-<ID>-2026-08-24" \
  -d '{"productId":"<ID>"}'
#   -> 202 { data: { id: "job_..." }, meta: { credits: { charged: 1, remaining: N } } }

# 6. Acompanhe (ou receba job.succeeded por webhook).
curl -s -H "Authorization: Bearer $K" "$B/v2/jobs/job_..."

# 7. Leia o resultado.
curl -s -H "Authorization: Bearer $K" "$B/v2/products/<ID>"

Regras para um lote saudável:

  • Concorrência: respeite o balde heavy do plano (ver §6). Em enterprise são 120/min; em starter, 5/min. Leia X-RateLimit-Remaining e pare antes do 429.
  • Idempotency-Key derivado do produto (ex.: enrich-<productId>-<data>): se o processo cair e reiniciar, o retry replica a resposta em vez de cobrar de novo.
  • Não faça polling apertado por job. Siga o Retry-After: 3, ou registre um webhook e não faça polling nenhum.
  • Trate 402 como fim de lote, não como erro por item: details.deficit diz quanto falta.
  • Se perdeu o id do job, recupere por GET /v2/jobs?resourceId=<productId>&kind=product_enrichment.

Receita: criar um post e mandar a IA escrever

Escopos: blog:write + blog:generate (+ jobs:read para acompanhar).

# 1. Crie a pauta (grátis, 201). Guarde data.id.
curl -s -X POST "$B/v2/blog-posts" \
  -H "Authorization: Bearer $K" -H "Content-Type: application/json" \
  -H "Idempotency-Key: post-churrasco-2026-10" \
  -d '{"title":"Como escolher a cerveja certa para o churrasco","keywords":["cerveja para churrasco"]}'

# 2. Mande gerar (3 créditos, 202 + Job).
curl -s -X POST "$B/v2/blog-posts/<POST_ID>/generate" \
  -H "Authorization: Bearer $K" -H "Idempotency-Key: gen-<POST_ID>-1"

# 3. Acompanhe o job (ou espere o webhook job.succeeded).
curl -s -H "Authorization: Bearer $K" "$B/v2/jobs/job_..."

# 4. Leia o texto pronto e, se quiser, publique.
curl -s -H "Authorization: Bearer $K" "$B/v2/blog-posts/<POST_ID>"
curl -s -X PATCH "$B/v2/blog-posts/<POST_ID>" \
  -H "Authorization: Bearer $K" -H "Content-Type: application/json" \
  -d '{"status":"PUBLISHED"}'

Para categoria é o mesmo desenho, com content:write + content:generate: POST /v2/category-contents → POST /v2/category-contents/{id}/generate (2 créditos) → GET /v2/category-contents/{id}. Se a categoria já existe, o 409 traz o existingCategoryContentId — pule direto para a geração (com "force": true se a página já tem texto e você quer reescrever).


13. O que a v2 ainda não tem

Para o agente não ficar procurando:

  • Criar e gerar são duas chamadas — não há um POST que crie o post/categoria e já dispare a geração. É de propósito: a criação é grátis e síncrona, a geração cobra e é assíncrona.
  • Sem DELETE de conteúdo de categoria — arquive com PATCH {"status":"ARCHIVED"}.
  • Sem criação de produto em lote — POST /v2/products cria um por chamada (seção 10.3), e não há rota de importação (VTEX/Shopify/XML/sitemap) na v2: isso ainda é só pelo painel.
  • Sem enriquecimento em lote — uma chamada por produto.
  • Sem edição de manual de marca / estilos de escrita (brand:write sem rota).
  • Sem criação ou edição de projetos de rank tracking (seo:write sem rota) — só listar, ler e rodar.
  • Sem sandbox: chaves _test_ são recusadas com 403.
  • Sem cursor real na paginação: nextCursor é sempre null hoje.
  • credits.low está no vocabulário de webhooks mas nada o emite ainda.
  • Sem OpenAPI da v2: a referência é este documento; a spec em /api-docs/openapi.json cobre só a v1.

14. Diferenças em relação à v1 (se você já integrou com ela)

v1 v2
Host/caminho admin.ozygen.app/api/v1 platform.ozygen.app/v2
Envelope de sucesso { "data": … } { "data": …, "meta": { requestId, … } }
Envelope de erro { "error": "msg", "code": "X" } { "error": { code, message, details, requestId, docsUrl } }
Falha assíncrona 200 com status: "failed" no corpo status HTTP correto + Job com error
Escopos CSV livre ("read,write") vocabulário fechado e validado
Paginação ?page= ?limit= + ?offset=
Rate limit 60/min fixo por plano, três baldes, contadores exatos nos headers
Idempotência não existia Idempotency-Key, obrigatória nas operações pagas
Acompanhar trabalho não existia /v2/jobs + webhooks assinados

Chaves antigas continuam funcionando: o CSV legado é traduzido para escopos (read → todos os :read, write → todos os :write, enrichment → enrichment:write).


15. Checklist de diagnóstico

Sintoma Causa provável
401 UNAUTHENTICATED falta Authorization: Bearer, ou a chave não é oz_sk_live_/oz_pk_live_
403 FORBIDDEN "Sandbox keys are not available yet" chave _test_; gere uma live
403 INSUFFICIENT_SCOPE compare details.required com details.granted; lembre que products:write ≠ enrichment:write
403 PUBLISHABLE_KEY_FORBIDDEN rota exige chave secreta
400 VALIDATION_ERROR em Idempotency-Key operação que gasta crédito sem o header
404 NOT_FOUND num id que existe o recurso é de outra organização (a chave define a org)
409 ALREADY_RUNNING já há run em andamento para o projeto; nada foi cobrado
429 RATE_LIMITED veja Retry-After e o balde da rota (§6)
GET /v2/jobs vazio a organização nunca iniciou trabalho assíncrono — não é erro
404 em /v2/... no host admin no host admin o prefixo é /api/v2/...
Job succeeded mas produto sem conteúdo leia result.href; se description não mudou, verifique estilo de escrita com targetAudience

Em qualquer chamada para suporte, informe o X-Request-Id da resposta: é a chave de busca no log do servidor.