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 emhttps://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:writenão autoriza enriquecimento. Enriquecer exigeenrichment:write. Confira o que sua chave realmente tem emGET /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.
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
2xxe4xxsão guardadas para replay;5xxlibera 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 baldepollcomporta e não consome cota diária. - O
GETreconcilia 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/jobslista o histórico: filtrosstatus,kind,resourceId,createdAfter,createdBefore(ISO-8601),limit,offset.?resourceId=<id do produto>recupera o job de um recurso quando você perdeu oiddo 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:
tagsvem preenchido emGET /v2/products(lista), mas o detalheGET /v2/products/{id}sempre devolvetags: []— 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é sempreMANUAL. A chave única é(organização, source, sourceId); deixar o chamador declararsource: "VTEX"permitiria colidir com — ou encobrir — linhas que o importador da VTEX é dono.organizationIdvem 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-Keynã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 mandadoskunemsourceId.
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. Mandartitlenão dá erro — a chave é simplesmente descartada. Para mudar o título de SEO, usemetaTitle.
imageseattributesaqui são strings (JSON serializado), não arrays — assimetria com o GET, que devolveimagescomo 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
targetAudiencepreenchido — é entrada não-negociável do motor. Verifique emGET /v2/writing-formats; - o produto tem alguma
descriptionde 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_RUNNINGcomdetails.existingRunId, sem cobrar. - Custo cresce com o projeto — use
?dryRun=trueantes.
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: oslugdesduplica com sufixo, então um retry sem a chave cria dois posts (como-escolher…ecomo-escolher…-1) em vez de dar409.
tagsno GET: antes da geração traz askeywordsque 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,authorIdeseo.schemaMarkupaceitamnullpara limpar.status:DRAFT|PUBLISHED|ARCHIVED. Publicar gravapublishedAte, se o post não temcanonicalUrl, derivahttps://<domínio da organização>/blog/<slug>. Se você mandacanonicalUrljunto comPUBLISHED, vale o seu.- Mudar
titlemuda oslug. 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
statusnão cria versão. tagsnã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 oschemaMarkupsozinho: mande os dois juntos, ou gere de novo.url,slug,parentIdeseo.schemaMarkupaceitamnull. Uma categoria não pode ser mãe dela mesma (400).status: "PUBLISHED"gravapublishedAtna primeira publicação.- Renomear
categorypara um nome que outra página já usa →409 DUPLICATE. relatedLinksnão é editável: sai da árvore de categorias e é refeito na geração.- Toda edição guarda a versão anterior no histórico (
versionsobe 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:
- Use o corpo bruto, antes de qualquer
JSON.parseou reserialização. - Rejeite fora da janela de 300 s — é o que torna o anti-replay real.
- 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}/deliveriestraz 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-secretcom{"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
heavydo plano (ver §6). Ementerprisesão 120/min; emstarter, 5/min. LeiaX-RateLimit-Remaininge pare antes do429. Idempotency-Keyderivado 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
402como fim de lote, não como erro por item:details.deficitdiz quanto falta. - Se perdeu o
iddo job, recupere porGET /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
POSTque 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
DELETEde conteúdo de categoria — arquive comPATCH {"status":"ARCHIVED"}. - Sem criação de produto em lote —
POST /v2/productscria 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:writesem rota). - Sem criação ou edição de projetos de rank tracking (
seo:writesem rota) — só listar, ler e rodar. - Sem sandbox: chaves
_test_são recusadas com403. - Sem cursor real na paginação:
nextCursoré semprenullhoje. credits.lowestá 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.jsoncobre 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.
