spicyapiDocumentação
Primeiros passos
Conteúdo principal

Guia de início rápido

Obtenha uma chave, crie uma tarefa e receba o resultado. O fluxo completo, do início ao fim.

A SpicyAPI reúne a geração de imagens e vídeos em um único protocolo de autenticação, tarefas e resultados, e oferece modelos de texto nos formatos OpenAI, Anthropic e Gemini que você já usa. Mantenha o mesmo fluxo de chamadas ao trocar de modelo e monte input a partir do inputSchema desse modelo — a lista dos campos aceitos, seus tipos e valores permitidos.

Este guia usa a API nativa de tarefas em https://api.spicyapi.ai/api/v1. Os endpoints de compatibilidade para texto e vídeo usam https://api.spicyapi.ai/v1. Ambos aceitam a mesma chave de API, mas os formatos das solicitações e respostas são diferentes.

Siga esta página para escolher um modelo e obter um resultado. Para consultar o preço exato antes de pagar, veja Estimativas e compatibilidade. Para chat, veja Texto e streaming. Antes de colocar sua integração em produção, siga Integração em produção; consulte Solução de problemas quando alguma etapa falhar. O glossário explica os termos menos familiares.

Quer ver um resultado antes de escrever código?

Abra qualquer modelo em spicyapi.ai/explore e use a aba Playground; a aba API mostra a mesma solicitação em um comando que você pode copiar. Para trabalhar pelo terminal sem escrever código, use a CLI ou deixe um assistente de IA fazer isso pelo servidor MCP.

Ferramentas oficiais

Chamadas diretas à API HTTP têm suporte completo e são o que o restante desta página apresenta. Se você prefere não escrever essas chamadas, há quatro pacotes npm com licença MIT, cada um instalado separadamente e com seu próprio guia:

Outros quatro SDKs oficiais atendem às linguagens fora do Node.js. Todos implementam o mesmo fluxo e usam a licença MIT. No momento, apenas o módulo Go pode ser instalado pelo comando habitual a partir de seu repositório; as outras três páginas mostram a instalação a usar até a publicação dos pacotes:

Você trabalha no ComfyUI? Os nodes do ComfyUI colocam cada modelo de imagem, vídeo e áudio no seu canvas como um node, exibindo o preço antes da execução.

Antes de começar

Você precisa de quatro coisas. Se pular alguma, receberá mais adiante um erro que não informa qual delas está faltando.

  1. Uma conta com e-mail verificado. Cadastre-se em spicyapi.ai e clique no link do e-mail de verificação. Até fazer isso, todas as chamadas com sua chave retornam 403.
  2. Saldo. As gerações são pagas com saldo em dólares americanos — não há créditos — e uma conta nova começa com saldo zero. Adicione fundos em Console → Faturamento. Sem saldo suficiente, a criação de uma tarefa falha com o código de negócio 40201. Configuração da conta explica como se cadastrar, adicionar saldo e criar uma chave.
  3. Um terminal. No macOS: o aplicativo Terminal. No Linux: qualquer shell. No Windows: PowerShell. Quando os comandos diferem, cada etapa oferece uma aba Windows PowerShell. Se preferir, os comandos de macOS / Linux também funcionam sem alterações no WSL ou no Git Bash para Windows.
  4. Alguns centavos. A etapa 3 cria uma tarefa real de imagem, cobrada quando é concluída com sucesso. A etapa 2 mostra o preço de cada modelo e oferece um comando opcional para consultar gratuitamente o valor exato antes.

Avalie primeiro: modelos e preços sem uma conta

Você não precisa de chave nem de conta para ver os modelos disponíveis e seus preços. O catálogo público usado pelo site pode ser consultado anonimamente, e este comando não exige nenhuma substituição:

Public catalogue — no key, no account
curl "https://api.spicyapi.ai/console/v1/catalog/models?locale=en"

O envelope da resposta é o mesmo descrito no final desta página. Em data.items, cada entrada contém:

  • model — o valor exato que você enviará depois como model (slug contém o mesmo valor);
  • callable — se o modelo pode ser executado agora. Escolha entradas em que seja true: status representa nossa intenção operacional, e um modelo live ainda pode estar temporariamente indisponível para chamadas;
  • modality e tasks — o tipo de saída produzido;
  • price — a faixa mais barata: amount em USD por unit (per_image, per_second, per_request ou per_1k_tokens), além de tiers, o número de faixas disponíveis. Para modelos de texto, consulte price.perMillionTokens, que separa os preços de entrada, entrada em cache e saída por milhão de tokens; amount, isoladamente, é apenas a tarifa de saída.

Acrescente o ID de um modelo para obter seu registro completo, incluindo inputSchema, paramNotes e examples prontos para uso:

One model, in full — replace MODEL_ID_FROM_CATALOG with a model value from the list above
curl "https://api.spicyapi.ai/console/v1/catalog/models/MODEL_ID_FROM_CATALOG?locale=en"

MODEL_ID_FROM_CATALOG é um marcador usado nesta documentação, não um modelo real: sempre copie um ID real do catálogo ao vivo.

O valor padrão de locale é en; envie ja, ko, de, fr, es, pt-BR, ru, it ou pl para obter nomes, resumos e notas de parâmetros traduzidos. Qualquer outro valor retorna 400. strict=1 remove entradas cujo texto não está publicado nesse idioma, em vez de usar o inglês como alternativa; normalmente, você não precisa dessa opção. Esse endpoint aplica limites por endereço de origem, não por conta, e possui uma cota própria — veja Limites de solicitações. Ele também usa cache, portanto uma alteração de preço pode demorar um pouco para aparecer aqui.

Quando tiers é maior que 1, o price listado representa a faixa mais barata, não o preço das suas configurações. price.variant mostra as configurações dessa faixa — por exemplo, 720p ou generate_audio=false;resolution=480p quando vários campos são combinados. No registro individual do modelo, inputSchema["x-pricing"].variantFields, quando presente, lista todos os campos que selecionam uma faixa. O campo pricingNote lembra de forma geral que a estimativa ao vivo é a referência válida; ele não lista campos. Para saber o valor exato de uma solicitação, use POST /api/v1/jobs/quote depois de obter uma chave.

Catálogo público para avaliar; catálogo autenticado para fazer chamadas

O catálogo público responde a “o que está à venda e qual é o preço anunciado”. Ele não responde a “o que minha chave pode chamar” nem a “quanto minha conta será cobrada”, porque ambas as respostas dependem da conta. Depois de obter uma chave, use GET /api/v1/models na etapa 2 como a única fonte de IDs disponíveis para chamada — um ID lembrado de cabeça, um retrato do catálogo no lançamento ou uma página de marketing não são alternativas.

Cinco etapas

Obtenha uma chave

Uma chave de API é a senha que seu código envia em cada solicitação. Ela identifica sua conta e permite atribuir a cobrança.

  1. Abra Console → Chaves de API e clique em Create key.
  2. Dê um nome à chave, como my-first-test. Mantenha as opções de Advanced como estão.
  3. Copie a chave que começa com sk-spicy- ou clique em Save as a file. Ela é exibida apenas uma vez; se você a perder, terá de criar outra.
  4. No terminal que usará nas próximas etapas, salve-a em uma variável de ambiente — um valor nomeado que o terminal passa a cada comando executado — para não precisar digitar a chave dentro de um comando:
export SPICY_API_KEY="sk-spicy-paste-your-key-here"

# Check: this should print sk-spicy-
echo "${SPICY_API_KEY:0:9}"

O que você deve ver: sk-spicy-. O valor permanece até você fechar essa janela do terminal, portanto execute todas as etapas seguintes na mesma janela.

Se não houver um botão Create key, sua função na equipe atual não permite criar chaves: peça ao proprietário da equipe ou use Start your own team na mesma página. Novas chaves não têm limite de gastos, a menos que você configure um; um limite diário no console estabelece um teto para gastos causados por configuração incorreta ou vazamento. Veja Autenticação.

Escolha um modelo disponível para chamada

O catálogo autenticado é a única fonte de IDs disponíveis para chamada, schemas atuais e exemplos. Consulte-o em duas chamadas: a lista é leve e mostra o que você pode chamar; depois, um registro individual fornece o schema do modelo escolhido. A consulta ao catálogo é gratuita.

1. What can this key call?
curl "https://api.spicyapi.ai/api/v1/models?modality=image&task=text-to-image" \
  -H "Authorization: Bearer $SPICY_API_KEY"

A resposta é longa. A parte necessária se parece com esta versão reduzida:

{
  "code": 200,
  "msg": "success",
  "data": {
    "total": 1,
    "items": [
      {
        "model": "MODEL_ID_FROM_CATALOG",
        "displayName": "…",
        "modality": "image",
        "tasks": ["text-to-image"],
        "enabled": true,
        "available": true,
        "startingPrice": { "variant": "", "unit": "per_image", "price": "0.008", "currency": "USD" }
      }
    ]
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}

Escolha uma entrada em que enabled e available sejam ambos true, confira o preço, copie o valor de model e use-o no restante desta página. Com jq instalado, curl … | jq -r '.data.items[] | select(.enabled and .available) | .model' imprime apenas os IDs disponíveis para chamada.

# Replace MODEL_ID_FROM_CATALOG with the model value you copied
export SPICY_MODEL="MODEL_ID_FROM_CATALOG"
2. What fields does that one model take?
curl "https://api.spicyapi.ai/api/v1/models/${SPICY_MODEL}" \
  -H "Authorization: Bearer $SPICY_API_KEY"

As barras do ID passam pelo caminho sem alterações; aplique codificação percentual apenas se o cliente HTTP exigir que um parâmetro de caminho ocupe um único segmento. O inputSchema do registro define o que é aceito em input: required lista os campos obrigatórios e properties descreve cada campo. examples contém entradas já validadas pelo servidor. Para interpretar o restante do registro, incluindo pricing, veja Catálogo de modelos e schemas.

Solicite schemas apenas quando precisar deles. Adicionar includeSchema=1&includeExamples=1 à chamada da lista retorna o JSON Schema completo de todos os modelos habilitados em uma única resposta, com um corpo muito maior. As consultas ao catálogo usam a capacidade de leitura da conta, independentemente da criação de tarefas. Também não releia o catálogo antes de cada tarefa; veja Cache do catálogo.

Se nenhuma entrada corresponder, pare antes de criar uma tarefa paga. Um retrato do lançamento, uma página de marketing ou um ID lembrado de cabeça não substituem um catálogo ao vivo vazio. Para selecionar o modelo pelo código, veja Seleção do modelo pelo código.

Opcional: consulte o preço exato primeiro. Uma estimativa é gratuita, não cria nada e não reserva saldo. data.estimatedCost informa o custo máximo dessa solicitação exata, em USD:

Optional quote (macOS / Linux)
curl -X POST https://api.spicyapi.ai/api/v1/jobs/quote \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"input\":{\"prompt\":\"A cinematic night portrait\"}}"

Crie uma tarefa

Uma tarefa é um trabalho de geração executado em segundo plano: você recebe o ID imediatamente e o resultado depois. Esta etapa tem custo.

Cada tarefa nova recebe uma nova chave de idempotência: um ID aleatório anexado à solicitação para que o envio repetido da mesma solicitação, por exemplo após um erro de rede, nunca crie duas tarefas. Gere a chave em um comando separado e depois envie a tarefa:

3a. A new key for each new image (run once per image)
SPICY_IDEMPOTENCY_KEY="$(uuidgen)"
# Linux without uuidgen: SPICY_IDEMPOTENCY_KEY="$(cat /proc/sys/kernel/random/uuid)"
3b. Send the task (safe to run again after a network error)
curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"input\":{\"prompt\":\"A cinematic night portrait\"}}"

Se 3b atingir o tempo limite ou a conexão cair, execute 3b novamente sem executar 3a: a mesma chave retorna a tarefa já iniciada, evitando uma segunda cobrança. Para gerar outra imagem, execute 3a primeiro. Uma resposta 409 significa que você reutilizou uma chave com outro prompt ou modelo. Saiba mais em Idempotência.

Esta solicitação envia um campo. Se o schema consultado na etapa 2 marcar outros campos como obrigatórios, inclua-os — cada modelo define em seu próprio schema o que pode estar em input, e um único campo desconhecido resulta em erro 400.

A conexão nunca fica aberta aguardando a geração. Você recebe imediatamente um ID de tarefa:

HTTP/1.1 202 Accepted
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "state": "queued",
    "estimatedCost": "0.008",
    "deadlineAt": "2026-08-28T09:22:04Z"
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}

No macOS / Linux, copie taskId da resposta e guarde-o — o PowerShell já fez isso por você:

export SPICY_TASK_ID="paste-the-taskId-here"

202 significa aceita, não concluída

Uma tarefa recém-criada tem state: "queued"; um reenvio seguro com a mesma Idempotency-Key retorna a tarefa original em seu estado atual. HTTP 202 indica que a solicitação foi aceita, não que a geração terminou. Quando a tarefa é aceita, o saldo é reservado, não cobrado: estimatedCost é separado do seu saldo. Essa reserva também é o teto da cobrança da tarefa: o valor não utilizado é liberado e nenhuma quantia acima desse teto é cobrada posteriormente. Tarefas que falham normalmente liberam a reserva; determinadas recusas de conteúdo, informadas na página do modelo, são cobradas pelo valor da estimativa aceita. Confira settled e cost. deadlineAt é o prazo máximo para a conclusão, após o qual a tarefa termina como expired. O code de negócio do envelope continua sendo 200 para uma solicitação bem-sucedida. Uma tarefa aceita não pode ser cancelada.

Obtenha o resultado

Se sua solicitação em produção incluir uma callBackUrl, enviaremos uma chamada de retorno quando a tarefa chegar a um estado terminal: um callback, ou webhook, é um endereço HTTPS no seu servidor que chamamos quando a tarefa termina, para que você não precise consultar repetidamente. A solicitação mínima acima não inclui esse campo, permitindo executá-la sem um endpoint público. É obrigatório verificar a assinatura dos callbacks — veja Webhooks.

Sem um endpoint de callback, consulte a tarefa:

curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${SPICY_TASK_ID}" \
  -H "Authorization: Bearer $SPICY_API_KEY"

Use a mesma chave de API que criou a tarefa: outra chave recebe 404. Confira data.state:

data.stateSignificadoO que fazer
queued ou runningA geração ainda está em andamento. O saldo está reservado, não foi gastoAguarde alguns segundos e execute o mesmo comando novamente. Imagens costumam levar segundos; vídeos podem levar minutos. deadlineAt é o limite máximo
succeededConcluídaPasse para a próxima etapa
failed ou expiredA geração não deu certo. A reserva normalmente é liberada; algumas recusas de conteúdo explicitamente informadas na página do modelo são cobradas. Confira settled e costLeia data.errorCode — estável — e data.errorMessage — legível por pessoas —, corrija a entrada e recomece em 3a

O code do envelope é 200 mesmo quando a tarefa falha: code indica se a consulta à tarefa funcionou. O resultado da tarefa fica sempre em data.state. Veja a resposta em cada estágio, em versões reduzidas:

{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "MODEL_ID_FROM_CATALOG",
    "state": "running",
    "cost": "0.008",
    "settled": false,
    "createdAt": "2026-08-28T09:12:04Z",
    "deadlineAt": "2026-08-28T09:22:04Z"
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w4"
}

Enquanto settled for false, cost é o valor reservado, não a cobrança final.

Baixe o arquivo gerado

Copie data.output.assets[0].url do resultado bem-sucedido e baixe o arquivo diretamente. Não envie sua chave de API. O link funciona por cerca de 20 minutos (expiresAt); se tiver expirado, execute novamente a etapa 4, pois cada consulta retorna um link novo. Os arquivos gerados são excluídos em no máximo 14 dias, portanto mantenha uma cópia própria de tudo o que precisar.

# Paste data.output.assets[0].url. Keep the double quotes: the URL contains & characters.
RESULT_URL="paste-the-url-here"
curl -L "$RESULT_URL" -o result.png   # use result.mp4 when mime is video/mp4

Com jq instalado, você pode dispensar a cópia manual:

RESULT_URL="$(curl -s "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${SPICY_TASK_ID}" \
  -H "Authorization: Bearer $SPICY_API_KEY" | jq -r '.data.output.assets[0].url')"

O que você deve ver: um arquivo result.png na pasta atual do terminal. Confira a cobrança em Console → Logs ou em cost quando settled for true.

Modelos de texto: uma solicitação, a resposta na mesma conexão

Modelos de texto respondem na mesma resposta HTTP: não é preciso consultar o status nem baixar um arquivo. O cabeçalho de resposta X-Spicy-Task-Id identifica a tarefa que registra a cobrança, caso queira consultá-la depois. Na etapa 2, liste os modelos usando modality=text, salve um deles em SPICY_MODEL e execute:

curl https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one sentence.\"}]}"

A resposta está em choices[0].message.content. Essa chamada é cobrada pelos tokens utilizados. O endpoint usa o formato de resposta OpenAI, não o envelope {code, msg, data}. Veja Texto e streaming.

Seleção do modelo pelo código

As cinco etapas acima mantêm uma pessoa no processo de escolha. Uma integração em produção seleciona o modelo por conta própria, imediatamente antes do envio: um modelo disponível na semana passada pode estar pausado hoje, e os preços dependem da conta. Solicite o schema e um exemplo validado pelo servidor na mesma chamada e exija enabled e available:

Select a live text-to-image model (requires jq)
CATALOG_JSON="$(curl --fail-with-body \
  "https://api.spicyapi.ai/api/v1/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1" \
  -H "Authorization: Bearer $SPICY_API_KEY")"

MODEL_ID="$(printf '%s' "$CATALOG_JSON" | jq -er \
  '[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].model')"
MODEL_INPUT="$(printf '%s' "$CATALOG_JSON" | jq -cer \
  '[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].examples[0].input')"

printf 'Selected %s\n' "$MODEL_ID"
Create the task from those two values (this costs money)
SPICY_IDEMPOTENCY_KEY="$(uuidgen)"   # new task, new key; reuse it only to resend this same request
TASK_PAYLOAD="$(jq -cn \
  --arg model "$MODEL_ID" \
  --argjson input "$MODEL_INPUT" \
  '{model: $model, input: $input}')"

curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
  --data "$TASK_PAYLOAD"

Se a seleção não retornar nada, pare nesse ponto em vez de recorrer a um ID lembrado de cabeça. Os três programas abaixo fazem a mesma seleção sem jq.

Juntando tudo

Os três programas abaixo consultam o catálogo, criam uma tarefa, verificam o status com intervalos crescentes e imprimem uma URL de download. Em produção, use webhooks — a consulta repetida é a alternativa de contingência, não a estratégia principal.

Executar estes programas cria uma tarefa real e paga

Cada programa escolhe o primeiro modelo disponível de texto para imagem no catálogo da sua conta — não o mais barato — e envia um de seus exemplos validados. Confira o preço desse modelo antes ou ajuste a seleção para o modelo exato que deseja.

LinguagemSalve comoInstale primeiroExecute
Node.js 20 ou posteriorgenerate.mjsnadanode generate.mjs
Python 3.9 ou posteriorgenerate.pypip install requestspython3 generate.py (Windows: py generate.py)
Go 1.21 ou posteriormain.go em uma pasta vaziago mod init examplego run .

Os três leem a chave de SPICY_API_KEY, definida na etapa 1, e imprimem a URL do resultado quando a tarefa termina. Eles aguardam até dez minutos, um prazo adequado para modelos de imagem; para vídeo, aguarde até o deadlineAt da tarefa.

Node.js 20+
const BASE = 'https://api.spicyapi.ai/api/v1';
const KEY = process.env.SPICY_API_KEY;

const headers = {
  Authorization: `Bearer ${KEY}`,
  'Content-Type': 'application/json',
};
async function call(path, init) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { ...headers, ...init?.headers },
    signal: AbortSignal.timeout(30_000),
  });
  const body = await res.json();
  // Branch on `code`, not on res.ok — the business code says far more
  if (body.code !== 200) {
    throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
  }
  return body.data;
}

async function generate() {
  const catalog = await call(
    '/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1',
  );
  const selected = catalog.items.find(
    (item) => item.enabled && item.available && item.examples?.length,
  );
  if (!selected) throw new Error('No callable text-to-image model with a validated example');

  const idempotencyKey = crypto.randomUUID();
  const { taskId } = await call('/jobs/createTask', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey },
    body: JSON.stringify({
      model: selected.model,
      input: selected.examples[0].input,
    }),
  });

  // Start at 2s, multiply by 1.5, cap at 15s. The first check is almost
  // certainly still `queued`, so a shorter start just wastes a round trip.
  const deadline = Date.now() + 10 * 60_000;
  let wait = 2000;
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, wait));
    wait = Math.min(wait * 1.5, 15000);

    const task = await call(`/jobs/recordInfo?taskId=${taskId}`);
    if (task.state === 'succeeded') {
      const asset = task.output?.assets?.find((item) => item.url);
      if (asset) return asset.url;
      if (task.output?.assets?.some((item) => item.pending)) continue;
      throw new Error('Result is unavailable');
    }
    // Anything other than queued / running is terminal. This also handles
    // historical canceled records and future terminal states safely.
    if (task.state !== 'queued' && task.state !== 'running') {
      throw new Error(`${task.state}: ${task.errorMessage ?? 'no detail'}`);
    }
  }
  throw new Error(`task ${taskId} timed out`);
}

generate().then(console.log);
Python 3.9+
import os, time, uuid, requests

BASE = "https://api.spicyapi.ai/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
    "Content-Type": "application/json",
}


def call(method: str, path: str, headers=None, **kwargs) -> dict:
    res = requests.request(
        method, f"{BASE}{path}", headers={**HEADERS, **(headers or {})}, timeout=30, **kwargs
    )
    body = res.json()
    # Branch on `code`, not on status_code
    if body["code"] != 200:
        raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
    return body["data"]


def generate() -> str:
    catalog = call(
        "GET",
        "/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
    )
    selected = next(
        (
            item
            for item in catalog["items"]
            if item["enabled"] and item["available"] and item.get("examples")
        ),
        None,
    )
    if selected is None:
        raise RuntimeError("No callable text-to-image model with a validated example")

    idempotency_key = str(uuid.uuid4())
    task_id = call("POST", "/jobs/createTask", headers={"Idempotency-Key": idempotency_key}, json={
        "model": selected["model"],
        "input": selected["examples"][0]["input"],
    })["taskId"]

    # Start at 2s, multiply by 1.5, cap at 15s
    deadline = time.monotonic() + 600
    wait = 2.0
    while time.monotonic() < deadline:
        time.sleep(wait)
        wait = min(wait * 1.5, 15.0)

        task = call("GET", "/jobs/recordInfo", params={"taskId": task_id})
        if task["state"] == "succeeded":
            assets = task.get("output", {}).get("assets", [])
            ready = next((asset for asset in assets if asset.get("url")), None)
            if ready:
                return ready["url"]
            if any(asset.get("pending") for asset in assets):
                continue
            raise RuntimeError("Result is unavailable")
        # Anything other than queued / running is terminal.
        if task["state"] not in {"queued", "running"}:
            raise RuntimeError(f'{task["state"]}: {task.get("errorMessage", "no detail")}')
    raise TimeoutError(f"task {task_id} timed out")


print(generate())
Go 1.21+
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"encoding/hex"
	"fmt"
	"math"
	"net/http"
	"os"
	"time"
)

const base = "https://api.spicyapi.ai/api/v1"

// envelope is the shape every endpoint returns. Data stays raw so each
// call site can decode it into whatever it actually expects.
type envelope struct {
	Code      int             `json:"code"`
	Msg       string          `json:"msg"`
	Data      json.RawMessage `json:"data"`
	RequestID string          `json:"request_id"`
}

var client = &http.Client{Timeout: 30 * time.Second}

func newIdempotencyKey() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return "job-" + hex.EncodeToString(b)
}

func call(method, path string, body any, idempotencyKey string, out any) error {
	var rdr *bytes.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rdr = bytes.NewReader(raw)
	} else {
		rdr = bytes.NewReader(nil)
	}

	req, err := http.NewRequest(method, base+path, rdr)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("SPICY_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}

	res, err := client.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return err
	}
	// Branch on Code, not on res.StatusCode
	if env.Code != 200 {
		return fmt.Errorf("%d %s (request_id=%s)", env.Code, env.Msg, env.RequestID)
	}
	return json.Unmarshal(env.Data, out)
}

func generate() (string, error) {
	var catalog struct {
		Items []struct {
			Model     string `json:"model"`
			Enabled   bool   `json:"enabled"`
			Available bool   `json:"available"`
			Examples  []struct {
				Input map[string]any `json:"input"`
			} `json:"examples"`
		} `json:"items"`
	}
	if err := call(http.MethodGet,
		"/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
		nil, "", &catalog); err != nil {
		return "", err
	}
	var modelID string
	var modelInput map[string]any
	for _, item := range catalog.Items {
		if item.Enabled && item.Available && len(item.Examples) > 0 {
			modelID, modelInput = item.Model, item.Examples[0].Input
			break
		}
	}
	if modelID == "" {
		return "", fmt.Errorf("no callable text-to-image model with a validated example")
	}

	var created struct {
		TaskID string `json:"taskId"`
	}
	err := call(http.MethodPost, "/jobs/createTask", map[string]any{
		"model": modelID,
		"input": modelInput,
	}, newIdempotencyKey(), &created)
	if err != nil {
		return "", err
	}

	// Start at 2s, multiply by 1.5, cap at 15s
	wait := 2 * time.Second
	deadline := time.Now().Add(10 * time.Minute)
	for time.Now().Before(deadline) {
		time.Sleep(wait)
		wait = time.Duration(math.Min(float64(wait)*1.5, float64(15*time.Second)))

		var task struct {
			State    string `json:"state"`
			ErrorMsg string `json:"errorMessage"`
			Output struct { Assets []struct { URL string `json:"url"`; Pending bool `json:"pending"` } `json:"assets"` } `json:"output"`
		}
		if err := call(http.MethodGet, "/jobs/recordInfo?taskId="+created.TaskID, nil, "", &task); err != nil {
			return "", err
		}

		if task.State == "succeeded" {
			pending := false
			for _, asset := range task.Output.Assets {
				if asset.URL != "" { return asset.URL, nil }
				pending = pending || asset.Pending
			}
			if pending { continue }
			return "", fmt.Errorf("result is unavailable")
		}
		// Terminal means "not one of the two in-flight states" — do not
		// enumerate the terminal states, that breaks when a new one appears
		if task.State != "queued" && task.State != "running" {
			return "", fmt.Errorf("%s: %s", task.State, task.ErrorMsg)
		}
	}
	return "", fmt.Errorf("task %s timed out", created.TaskID)
}

func main() {
	url, err := generate()
	if err != nil {
		panic(err)
	}
	fmt.Println(url)
}

O envelope da resposta

As respostas JSON da API nativa de tarefas usam este envelope; os endpoints de compatibilidade /v1 e SSE usam os formatos de seus próprios protocolos:

{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }
  • code é um código de negócio, não um status HTTP. 200 significa sucesso. O status HTTP também é definido corretamente, portanto você pode usar qualquer um deles para decidir o fluxo — mas escolha um. Os valores específicos divergem: saldo insuficiente tem code: 40201 e HTTP 402. Misturar os dois acabará fazendo você deixar algum caso passar.
  • Em caso de falha, data fica ausente — não recebe null.
  • request_id está presente tanto no sucesso quanto na falha, e também aparece no cabeçalho de resposta X-Request-Id. Informe esse valor ao relatar um problema.

Próximos passos

Nesta página