Upload de vídeos de produto¶
Vídeos enriquecem anúncios e páginas de produto. O Gubee processa, transcodeia
e distribui via CDN própria. Este documento descreve o fluxo completo de upload
via surface de integração (/integration/videos/*).
Para o fluxo equivalente no front web interno, veja
/bffweb/videos/*— contratos idênticos, apenas o prefixo do path muda.
Modelo¶
ERP/integrador BFF gubee-media bucket COS
│ │ │ │
│ POST /upload-url │ │ │
│───────────────────>│ POST /upload-url │ │
│ │────────────────────>│ │
│ │ presignedPutUrl │ │
│ │ + videoId │ │
│ │<────────────────────│ │
│ presignedPutUrl │ │ │
│ + videoId │ │ │
│<───────────────────│ │ │
│ │
│ PUT direto no bucket (bytes — NÃO passa pelo BFF) │
│─────────────────────────────────────────────────────────────────────────>│
│ 200/204 │
│<─────────────────────────────────────────────────────────────────────────│
│ │ │ │
│ POST /{id}/commit │ │ │
│───────────────────>│ HEAD + enqueue │ │
│ │────────────────────>│ │
│ 202 (UPLOADED) │ │ │
│<───────────────────│ │ │
│ │
│ [worker transcodeia em ~30-90s] │
│ │
│ GET /{videoId} │ │ │
│───────────────────>│────────────────────>│ │
│ status: READY │ │ │
│ cdnUrl + posterUrl│ │ │
│<───────────────────│ │ │
Pontos críticos:
- O BFF não proxia o binário do vídeo. O integrador faz
PUTdireto no bucket COS usando a URL pré-assinada. - O bucket tem CORS aberto para
https://*.gubee.com.br. Para upload de fora desse domínio (ERP server-side), CORS não se aplica. - O
videoIdé retornado em/upload-urle deve ser guardado — é a chave para commit, polling, anexação ao produto e eventual delete.
Limites por VideoType¶
| Tipo | Tamanho máximo | Duração máxima | Use case |
|---|---|---|---|
SHORT |
100 MB (104857600 B) | 90 segundos | Vídeo curto de produto, "stories" de ad. |
LONG |
500 MB (524288000 B) | 5 minutos | Demo detalhada, vídeo institucional. |
A validação acontece em 3 lugares:
/upload-url: contradeclaredSizeBytes(rejeita 400 imediatamente).- PUT no bucket: COS recusa se exceder.
/commit: HEAD no bucket revalida o tamanho real (rejeita 422 se divergir do declarado).
Status lifecycle¶
PENDING_UPLOAD ──PUT no bucket──► UPLOADED ──commit──► PROCESSING ──sucesso──► READY
│ │
│ └──falha──► FAILED
│
└──sem commit por >1h──► EXPIRED
READY/FAILED ──DELETE──► DELETED (soft; bytes apagados em 30 dias)
Passo 1 — Solicitar URL pré-assinada¶
POST /integration/videos/upload-url
Request¶
curl -X POST 'https://api.gubee.com.br/integration/videos/upload-url' \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"videoType": "SHORT",
"externalId": "SKU-1001-BLK",
"ownerType": "PRODUCT",
"ownerId": "sku-uuid-1001-blk",
"declaredMimeType": "video/mp4",
"declaredSizeBytes": 24576000,
"fileExtension": "mp4"
}'
Body (VideoUploadUrlRequest)¶
| Campo | Tipo | Obrigatório | Notas |
|---|---|---|---|
videoType |
"SHORT" / "LONG" |
sim | Define os limites de tamanho/duração. |
externalId |
string |
não | Natural key do seller (geralmente SKU). Vira parte do videoId. |
ownerType |
"PRODUCT" / "ANNOUNCEMENT" |
não | Pode ser informado aqui ou no commit. |
ownerId |
string |
não | ID do produto/anúncio dono. |
declaredMimeType |
string |
sim | Hoje apenas video/mp4 é processado. |
declaredSizeBytes |
long |
sim | Validado contra o limite do videoType. |
fileExtension |
string |
sim | Allowlist: mp4, mov, webm, mkv, avi. |
Response 200 (VideoUploadUrlResponse)¶
{
"videoId": "sellerA_SKU-1001-BLK",
"presignedPutUrl": "https://media.gubee.com.br/video/raw/sellerA/SKU-1001-BLK.mp4?X-Amz-Algorithm=...",
"bucketKey": "video/raw/sellerA/SKU-1001-BLK.mp4",
"expiresAt": "2026-07-07T13:30:00Z",
"maxBytes": 104857600
}
| Campo | Tipo | Descrição |
|---|---|---|
videoId |
string |
ID interno do gubee-media. Guarde para todos os próximos passos. |
presignedPutUrl |
string |
URL para PUT direto no bucket COS. Expira em expiresAt. |
bucketKey |
string |
Path interno no bucket (informativo). |
expiresAt |
ISO-8601 | Expiração do presignedPutUrl (geralmente +1h). |
maxBytes |
long |
Limite efetivo para este upload. |
Passo 2 — Upload direto no bucket COS¶
Faça PUT direto na URL pré-assinada. Não envie Authorization — a
autenticação é embutida na query string.
curl -X PUT \
-H 'Content-Type: video/mp4' \
--upload-file ./meu-video.mp4 \
'https://media.gubee.com.br/video/raw/sellerA/SKU-1001-BLK.mp4?X-Amz-Algorithm=...'
Respostas esperadas¶
| Status | Significado |
|---|---|
| 200 | Upload aceito (algumas libs retornam 204). |
| 403 | URL expirou ou assinatura inválida. Gere outra. |
| 413 | Excede o tamanho declarado. Reduza o arquivo ou suba como LONG. |
Não há retry explícito no bucket. Se o PUT falhar por erro de rede, refaça a operação dentro da janela de
expiresAt.
Passo 3 — Confirmar upload (commit)¶
POST /integration/videos/{videoId}/commit
Confirma que o PUT no bucket concluiu. O servidor faz HEAD no objeto, valida
o tamanho real, transiciona para UPLOADED e enfileira para o worker de
transcode.
curl -X POST 'https://api.gubee.com.br/integration/videos/sellerA_SKU-1001-BLK/commit' \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"ownerType": "PRODUCT",
"ownerId": "sku-uuid-1001-blk"
}'
Body (VideoCommitRequest) — opcional¶
Pode ser omitido se ownerType e ownerId foram informados no /upload-url.
Útil para o padrão eager upload — quando o ownerId (SKU/produto) ainda
não existia no momento do /upload-url e foi criado entre os dois passos.
Response 202 (VideoCommitResponse)¶
Erros comuns¶
| Status | Tipo | Causa |
|---|---|---|
| 400 | /errors/validation |
Payload mal-formado ou ownerType sem ownerId. |
| 404 | /errors/not-found |
videoId não existe ou não pertence ao seller. |
| 409 | /errors/invalid-state |
Status atual ≠ PENDING_UPLOAD (commit duplicado). |
| 410 | /errors/upload-expired |
presignedPutUrl expirou sem PUT. Gere outra. |
| 422 | /errors/video-size-exceeded |
HEAD revelou tamanho real > declarado. |
Passo 4 — Polling de status¶
GET /integration/videos/{videoId} — faça polling até status terminal.
curl 'https://api.gubee.com.br/integration/videos/sellerA_SKU-1001-BLK' \
-H 'Authorization: Bearer $TOKEN'
Response — READY¶
{
"videoId": "sellerA_SKU-1001-BLK",
"status": "READY",
"videoType": "SHORT",
"ownerType": "PRODUCT",
"ownerId": "sku-uuid-1001-blk",
"cdnUrl": "https://media.gubee.com.br/video/final/sellerA/SKU-1001-BLK.mp4",
"posterUrl": "https://media.gubee.com.br/video/poster/sellerA/SKU-1001-BLK.jpg",
"durationMs": 45000,
"width": 1080,
"height": 1920,
"sizeBytes": 8400000
}
Response — FAILED¶
{
"videoId": "sellerA_SKU-1001-BLK",
"status": "FAILED",
"videoType": "SHORT",
"ownerType": "PRODUCT",
"ownerId": "sku-uuid-1001-blk",
"failureReason": "EXCEEDS_DURATION",
"retryCount": 1
}
Estratégia de polling¶
- Inicie após ~5s do commit.
- Backoff exponencial: 5s, 10s, 15s, 20s, 20s, ...
- Timeout total: 5 min (worst case do
LONG). - Estados terminais:
READY(sucesso) ouFAILED(erro irrecuperável).
VideoStatus — todos os valores¶
| Status | Significado |
|---|---|
PENDING_UPLOAD |
URL emitida, esperando PUT no bucket. |
UPLOADED |
Commit feito, na fila do worker. |
PROCESSING |
Worker está transcodeando. |
READY |
Pronto. cdnUrl disponível. |
FAILED |
Erro irrecuperável. failureReason populado. |
EXPIRED |
PENDING_UPLOAD sem commit por mais de 1h. |
DELETED |
Soft-deleted pelo seller. |
VideoFailureReason¶
| Valor | Causa |
|---|---|
PROBE_FAILED |
FFprobe não conseguiu ler (arquivo corrompido). |
UNSUPPORTED_CODEC |
Codec fora da allowlist. |
EXCEEDS_DURATION |
Duração acima do limite do videoType. |
EXCEEDS_SIZE |
Tamanho real maior que o declarado. |
INVALID_DIMENSIONS |
Resolução abaixo do mínimo. |
TRANSCODE_TIMEOUT |
FFmpeg passou do timeout (240s default). |
TRANSCODE_FAILED |
FFmpeg crashou. |
UPLOAD_TO_BUCKET_FAILED |
Erro subindo resultado pro COS. |
INTERNAL_ERROR |
Outros erros ou retryCount esgotado. |
Listar vídeos¶
curl 'https://api.gubee.com.br/integration/videos?status=READY&limit=20&skip=0' \
-H 'Authorization: Bearer $TOKEN'
Query params¶
| Parâmetro | Tipo | Default | Descrição |
|---|---|---|---|
status |
VideoStatus |
— | Filtra por status. |
limit |
int | 50 | Tamanho da página. |
skip |
long | 0 | Offset. |
Retorna List<VideoStatusResponse> ordenado por data de criação descendente.
Anexar vídeo a uma variação de produto¶
Vídeos não são criados via payload de produto — eles já existem (você tem o
videoId). A anexação controla metadados de apresentação (main e
order).
Bulk via PUT /integration/products/{externalId}¶
Padrão round-trip: le o produto, ajusta videos[], re-envia integral.
curl -X PUT 'https://api.gubee.com.br/integration/products/prod-erp-1001' \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"...todos os campos do produto...",
"variations": [
{
"sku": "SKU-1001-BLK",
"skuId": "sku-uuid-1001-blk",
"videos": [
{ "videoId": "sellerA_SKU-1001-BLK", "main": true, "order": 0 },
{ "videoId": "sellerA_SKU-1001-INTRO", "main": false, "order": 1 }
]
}
]
}'
Regras do merge cirúrgico (bulk)¶
| Cenário | Comportamento |
|---|---|
videos ausente / vazio |
Lista atual preservada integralmente. |
Item com videoId já existente |
main e order aplicados. Demais campos preservados. |
Item com videoId desconhecido |
Descartado. Não é possível criar vídeo via PUT. |
| Vídeo omitido do payload | Não é deletado. Use DELETE /videos/{videoId}. |
Dois itens com main=true |
Backend não valida — ambos ficam main=true (indefinido). |
Campos cdnUrl, posterUrl, status, etc. |
Ignorados silenciosamente (round-trip seguro). |
Garanta exatamente um
main=truepor variação. O backend não valida essa unicidade.
Granular via PATCH¶
Para mudar um único vídeo sem reescrever a lista:
curl -X PATCH 'https://api.gubee.com.br/integration/products/prod-erp-1001/variations/sku-uuid-1001-blk/videos/sellerA_SKU-1001-BLK' \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"main": true,
"order": 0
}'
{identifier}no path aceitavideoIdouuuidlegacy — o backend tenta os dois.- Response:
{ "modified": true }(sucesso) ou{ "modified": false }(no-op: vídeo não existe na variação, ou body semmain/order).
Quando usar bulk vs granular¶
| Caso | Recomendado |
|---|---|
| Toggle "definir como principal" (1 vídeo) | PATCH .../videos/{videoId} com {main:true} |
| Reordenar a lista inteira (N vídeos) | PUT /products/{id} com videos[] compacto |
| Tela de edição completa do produto | PUT /products/{id} (já vai no payload) |
Soft-delete¶
DELETE /integration/videos/{videoId} marca como DELETED. Os bytes
permanecem no bucket por 30 dias (janela de rollback) e são removidos pelo
scheduler após esse período.
curl -X DELETE 'https://api.gubee.com.br/integration/videos/sellerA_SKU-1001-BLK' \
-H 'Authorization: Bearer $TOKEN'
Retorna 204 No Content.
Pós-delete¶
- Vídeo sai das listagens filtradas (
?status=READY). GET /integration/videos/{videoId}ainda retorna o documento comstatus: DELETEDpor 30 dias.cdnUrlpermanece acessível por 30 dias (caso o seller queira voltar atrás).- Após 30 dias: bytes apagados,
cdnUrleposterUrlviramnull. - O produto/anúncio dono é notificado via plataforma de streaming de eventos e o vídeo sai da lista em ~5s.
Vídeos enriquecidos no GET de produto¶
Em leitura (GET /integration/products/{externalId}), cada item de videos[]
vem com os campos do gubee-media preenchidos via evento assíncrono na plataforma de streaming:
{
"videos": [
{
"main": true,
"name": "video-principal.mp4",
"order": 0,
"url": "https://media.gubee.com.br/video/final/sellerA/SKU-001.mp4",
"uuid": "sellerA_SKU-001",
"videoId": "sellerA_SKU-001",
"cdnUrl": "https://media.gubee.com.br/video/final/sellerA/SKU-001.mp4",
"posterUrl": "https://media.gubee.com.br/video/poster/sellerA/SKU-001.jpg",
"durationMs": 45000,
"width": 1080,
"height": 1920,
"sizeBytes": 8400000,
"status": "READY",
"failureReason": null
}
]
}
Campos
cdnUrl,posterUrl,durationMs, etc. são populados em background quando o eventomediaservice.video.readychega — podem não estar presentes imediatamente após o commit. Refaça o GET em ~30-60s.
Replicação produto → anúncio¶
Anúncios têm updateOptions controlando quais campos replicam do produto
associado. Para replicar vídeos, inclua "VIDEOS" no set:
- Default:
VIDEOSdesligado — vídeos não replicam automaticamente. - Quando ligado, mudanças de vídeo no produto disparam re-attach automático no anúncio via plataforma de streaming de eventos.
Erros¶
Todos os erros vêm no formato Zalando Problem (application/problem+json).
| Status | Type URI | Quando |
|---|---|---|
| 400 | /errors/validation |
Payload inválido, videoType inválido. |
| 400 | /errors/video-size-exceeded |
declaredSizeBytes > maxBytes. |
| 400 | /errors/unsupported-video-format |
fileExtension fora da allowlist. |
| 403 | /errors/forbidden |
JWT inválido ou sem sellerId. |
| 404 | /errors/not-found |
videoId não existe ou sellerId não bate. |
| 409 | /errors/invalid-state |
Commit duplicado ou reprocess em estado inválido. |
| 410 | /errors/upload-expired |
URL pré-assinada expirou — gere outra. |
| 422 | /errors/video-size-exceeded |
Commit revelou tamanho real > declarado. |
| 5xx | (vários) | gubee-media indisponível. |
Exemplo completo (Python)¶
import os
import time
import requests
API = "https://api.gubee.com.br/integration/videos"
TOKEN = os.environ["TOKEN"]
def upload_video(file_path: str, video_type: str, owner_id: str, sku: str):
size = os.path.getsize(file_path)
mime = "video/mp4"
ext = file_path.split(".")[-1].lower()
# 1. Pega URL pré-assinada
resp = requests.post(
f"{API}/upload-url",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
json={
"videoType": video_type,
"externalId": sku,
"ownerType": "PRODUCT",
"ownerId": owner_id,
"declaredMimeType": mime,
"declaredSizeBytes": size,
"fileExtension": ext,
},
)
resp.raise_for_status()
presigned = resp.json()
video_id = presigned["videoId"]
put_url = presigned["presignedPutUrl"]
# 2. PUT direto no bucket — sem Authorization
with open(file_path, "rb") as f:
put = requests.put(put_url, headers={"Content-Type": mime}, data=f)
put.raise_for_status()
# 3. Commit
commit = requests.post(
f"{API}/{video_id}/commit",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
json={"ownerType": "PRODUCT", "ownerId": owner_id},
)
commit.raise_for_status()
assert commit.json()["status"] == "UPLOADED"
# 4. Poll status
for _ in range(60):
time.sleep(5)
status = requests.get(
f"{API}/{video_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
).json()
if status["status"] == "READY":
return status
if status["status"] == "FAILED":
raise RuntimeError(f"video failed: {status.get('failureReason')}")
raise TimeoutError("video processing timed out")
result = upload_video(
file_path="./demo-produto.mp4",
video_type="SHORT",
owner_id="sku-uuid-1001-blk",
sku="SKU-1001-BLK",
)
print(f"cdnUrl: {result['cdnUrl']}")
print(f"posterUrl: {result['posterUrl']}")
Próximos passos¶
- Sincronizar catálogo — criar produtos antes de anexar vídeos.
- Eventos de webhook — eventos
mediaservice.video.ready,mediaservice.video.failed,mediaservice.video.deleted.