# QR Dinâmico



<ToolHero tool="qr-dinamico" />

## Visão geral

O QR dinâmico separa a **imagem impressa** (link curto estável) do **destino** (editável sem reimprimir). Fluxo típico:

1. `POST` com `X-Dry-Run: true` para validar o payload e ver o custo.
2. `POST` definitivo com `X-Idempotency-Key` → recebe a imagem do QR, o `resource_id` e o `slug`.
3. Use a imagem retornada (ou gere a sua apontando para `full_url`, disponível em `GET /{id}`).
4. Acompanhe os scans via analytics ou pelo webhook `qr.dynamic.scanned`. O webhook é de melhor esforço (em picos de tráfego, alguns eventos podem não ser enviados); para contagens exatas, use o analytics.

<Callout type="warn" title="Redirect público cobra por scan">
  `GET /r/{short_id}` não usa autenticação, mas cada scan pode debitar créditos da conta dona do QR. Sem saldo, o QR pode ser desativado. Scans repetidos do mesmo visitante no mesmo QR em até 30 segundos são cobrados uma única vez: continuam redirecionando, mas não entram no analytics, não disparam o webhook nem consomem o `scan_limit`.
</Callout>

<EndpointList tool="qr-dinamico" />

## Custos

<PricingTable tool="qr-dinamico" group="creation" />

<PricingTable tool="qr-dinamico" group="domain" />

<PricingTable tool="qr-dinamico" group="batch" />

<PricingTable tool="qr-dinamico" group="scans" />

<PricingTable tool="qr-dinamico" group="storage" />

Leituras via API (listagem, detalhe, analytics, scans, verificação de slug) seguem a [franquia diária](/cobranca-e-limites#franquia-diaria). `PATCH`, ativar, desativar e `DELETE` são gratuitos.

## Criação e gestão

### Criar QR dinâmico

<Endpoint
  id="qr.create"
  example={{
  headers: { 'X-Idempotency-Key': '550e8400-e29b-41d4-a716-446655440000' },
  body: `{
"name": "Campanha verão",
"target_url": "https://loja.example.com/promo",
"custom_slug": "promo-verao",
"foreground_color": "#000000",
"background_color": "#ffffff"
}`,
}}
>
  Campos principais: `name` e `target_url` (o `target_url` é opcional quando há [`content`](#conteudo-hospedado)). As cores e o estilo ficam no próprio corpo (`foreground_color`, `background_color`, `module_style`, `eye_style`, `size`, `logo_data_url`…). Opcionais: `custom_slug`, `custom_domain_id` ([domínio próprio](#dominio-proprio)), `expires_at`, `scan_limit`, `skip_image_generation`, `utm_forwarding`, `folder_id` e `tags` ([organização](#organizacao)), `frame` e `dpi` ([impressão](#impressao)) e as opções de [comportamento do scan](#comportamento-do-scan) — cada uma pode somar custo (veja [Custos](#custos)). `response_types` escolhe os formatos da imagem (`svg`, `png`, `pdf` e `eps`; padrão `svg`): `svg` e `eps` vêm como texto, `png` e `pdf` em base64.

  A resposta traz as imagens, o `resource_id` (id do QR) e o `slug`. Com `skip_image_generation: true`, vem um único item só com `resource_id` e `slug`.
</Endpoint>

### Listar QRs

<Endpoint id="qr.list" example={{ query: { limit: '20', offset: '0' } }}>
  Query: `limit` (padrão 20, máx. <Limit name="pageSizeMax" />), `offset`, `search`, `folder_id` (um UUID, ou `none` para os QRs sem pasta), `tags` (separadas por vírgula; o QR precisa ter todas) e `status` (`active` ou `paused`).
</Endpoint>

### Detalhe

<Endpoint id="qr.get" />

### Atualizar destino, nome ou design

<Endpoint
  id="qr.update"
  example={{
  body: `{
"name": "Campanha atualizada",
"target_url": "https://loja.example.com/nova-promo"
}`,
}}
>
  Mudar nome, destino e cores é grátis. Ligar uma opção paga que estava desligada — `utm_forwarding`, `scan_limit` (de 0 para um valor), gradiente (`foreground_color_end`), olhos customizados (`eye_style` ou `eye_dot_style` `shield`/`flower`) ou logo — cobra o mesmo valor da [criação](#custos), uma única vez por atualização. Alterar uma opção já ligada ou desligá-la é grátis, sem estorno. Aceita `X-Dry-Run`, `X-Include-Cost` e `X-Idempotency-Key`; sem saldo a resposta é `402` e nada muda, uma atualização idêntica já cobrada responde `409`, e uma cor inválida é recusada (`400`) antes de cobrar. A nova `target_url` passa pelas verificações anti-malware e anti-loop. Como em ativar, desativar e excluir, a resposta é apenas uma confirmação — consulte `GET /{id}` para ver o recurso atualizado.
</Endpoint>

### Reativar

<Endpoint id="qr.activate" />

### Desativar

<Endpoint id="qr.deactivate">
  Um QR desativado mostra uma página de QR inativo no redirect público e continua ocupando slot até ser excluído.
</Endpoint>

### Excluir

<Endpoint id="qr.delete">
  Encerra a cobrança de armazenamento do slot.
</Endpoint>

### Verificar disponibilidade de slug

<Endpoint id="qr.check-slug" example={{ pathParams: { slug: 'promo-verao' } }} />

## Analytics

### Overview de analytics

<Endpoint id="qr.analytics" example={{ query: { period: 'last_30d' } }}>
  Agregações com cache. Query `period`: `last_7d`, `last_30d` (padrão), `last_90d` ou `all`; valores desconhecidos contam como 30 dias. `scan_history` traz os 20 scans mais recentes.
</Endpoint>

### Scans por período

<Endpoint id="qr.analytics-period" example={{ query: { period: 'last_30d' } }}>
  Retorna os totais e `period_stats` (scans por dia). Os campos `period`, `period_scans`, `period_cost` e `unique_scans` vêm zerados nesta rota — use o [overview](#qr-analytics) se precisar deles.
</Endpoint>

### Distribuição por dispositivo

<Endpoint id="qr.analytics-device" example={{ query: { period: 'last_30d' } }}>
  `device` é `Mobile`, `Desktop` ou `Tablet`. Assim como em scans por período, os campos de período vêm zerados.
</Endpoint>

### Histórico de scans

<Endpoint id="qr.scans" example={{ query: { limit: '50', offset: '0', period: 'last_30d' } }}>
  Paginação: `limit` (padrão 50, máx. <Limit name="pageSizeMax" />), `offset` e `period` (padrão: últimos 30 dias). `location` vem como `Unknown` quando cidade e país são desconhecidos.
</Endpoint>

## Redirect público

<Endpoint id="qr.redirect" example={false}>
  * **Ativo:** `307` para a `target_url` (com a query string repassada, se UTM forwarding estiver ativo). Em contas sem plano premium, o scan passa antes por uma página intermediária da marca que redireciona em 3 segundos.
  * **Inexistente, inativo, expirado, sem saldo, acima do limite de scans ou com destino inseguro:** redireciona para a página de QR indisponível, com o motivo em `?reason=` (`unknown`, `inactive`, `expired`, `bankrupt`, `limit_reached`, `safety_risk`, `rate_limited`).
  * **Muitos acessos do mesmo visitante:** acima de 600 por minuto (somando todos os QRs), redireciona para a página de QR indisponível com `?reason=rate_limited`. Acima de 30 scans por minuto no mesmo QR, o redirect continua, mas os scans extras não são contados nem cobrados.
  * As respostas nunca são cacheadas (`Cache-Control: no-store`).

  ```bash
  curl -sS -I 'https://api.respawntech.dev/r/promo-verao'
  ```
</Endpoint>

## Domínio próprio

Em vez de `api.respawntech.dev/r/promo-verao`, o QR pode viver em `https://qr.suaempresa.com/promo-verao`. Você aponta um **subdomínio seu** para a Respawn Tech com um registro CNAME; o certificado HTTPS é emitido automaticamente (Cloudflare for SaaS). O `hostname` precisa ser um subdomínio com pelo menos três rótulos (`qr.suaempresa.com`): um domínio raiz (apex) não aceita CNAME, e porta, caminho, IP e curinga são recusados.

O escopo é `tools:qr:domains:manage` (ou `tools:qr:dynamic:manage` / `tools:manage`). O limite de domínios depende do plano (`qr_domain.plan_limit`) e um domínio ativo é cobrado a cada 30 dias (veja [Custos](#custos)).

<Steps>
  <Step>
    ### Cadastre o domínio

    ```bash
    curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/domains' \
      -H "X-API-Token: $RESPAWN_API_TOKEN" \
      -H 'Content-Type: application/json' \
      -d '{
        "hostname": "qr.suaempresa.com",
        "validation_method": "http",
        "root_url": "https://www.suaempresa.com"
      }'
    ```

    `validation_method` é `http` (padrão) ou `txt`: é como o certificado é validado. A resposta (`201`) traz `status: "pending"` e a lista `records` com tudo o que você precisa publicar. Guarde o `id`.
  </Step>

  <Step>
    ### Publique o CNAME

    No painel de DNS do seu domínio, crie o registro do item com `purpose: "routing"`. O destino (`value`) vem na resposta; não o copie de exemplos.

    | Tipo    | Nome                          | Valor                           |
    | ------- | ----------------------------- | ------------------------------- |
    | `CNAME` | `qr` (ou `qr.suaempresa.com`) | o `value` do registro `routing` |

    No **Cloudflare**, crie o CNAME na zona `suaempresa.com` e deixe o proxy **desligado** (nuvem cinza, "somente DNS") até o domínio ficar `active`; só então, se quiser, ligue o proxy. Em outros provedores, é um CNAME comum para o subdomínio.
  </Step>

  <Step>
    ### Valide o certificado

    Se você escolheu `http`, o registro `purpose: "certificate"` com `type: "HTTP"` é validado sozinho assim que o CNAME propagar, sem nada a publicar. Se escolheu `txt`, publique também os registros `TXT` que vierem em `records` (`ownership` e `certificate`), no mesmo painel de DNS.
  </Step>

  <Step>
    ### Verifique até ficar ativo

    A plataforma consulta o domínio periodicamente; para não esperar, force a verificação:

    ```bash
    curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/domains/$DOMAIN_ID/refresh' \
      -H "X-API-Token: $RESPAWN_API_TOKEN"
    ```

    O `status` passa de `pending` para `active` (a cobrança do período acontece nesse momento). `failed` indica que o provedor recusou o hostname (veja `last_error`); `suspended`, que o período não pôde ser cobrado — os scans vão para a página de fallback até a próxima cobrança bem-sucedida.
  </Step>

  <Step>
    ### Crie QRs no domínio

    Mande o `id` do domínio em `custom_domain_id`. Um domínio ainda `pending` já é aceito, para você imprimir enquanto o DNS propaga, mas os scans só redirecionam depois que ele fica `active`.

    ```bash
    curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/dynamic' \
      -H "X-API-Token: $RESPAWN_API_TOKEN" \
      -H 'Content-Type: application/json' \
      -d '{
        "name": "Cartaz da loja",
        "target_url": "https://loja.example.com/promo",
        "custom_slug": "promo-verao",
        "custom_domain_id": "'"$DOMAIN_ID"'"
      }'
    ```

    O `full_url` do QR passa a ser `https://qr.suaempresa.com/promo-verao`. O slug só precisa ser único dentro do domínio.
  </Step>
</Steps>

`root_url` é para onde vai a raiz do domínio (`https://qr.suaempresa.com/`) e `not_found_url`, para onde vão slugs desconhecidos; sem eles, vale a página de fallback da plataforma. Nenhum dos dois pode apontar de volta para o próprio domínio. Só `root_url` e `not_found_url` mudam depois do cadastro (`PATCH`); para trocar o hostname, remova e cadastre de novo.

<Callout type="info" title="Remover um domínio">
  Um domínio com QRs ainda vinculados não pode ser removido (`qr_domain.in_use`). Exclua os QRs primeiro; depois de removido o domínio, o `full_url` deles deixa de funcionar.
</Callout>

### Listar domínios próprios

<Endpoint id="qr.domains-list" />

### Detalhe do domínio

<Endpoint id="qr.domains-get" />

### Cadastrar domínio próprio

<Endpoint
  id="qr.domains-create"
  example={{
  body: `{
"hostname": "qr.suaempresa.com",
"validation_method": "http",
"root_url": "https://www.suaempresa.com"
}`,
}}
/>

### Editar páginas de fallback do domínio

<Endpoint
  id="qr.domains-update"
  example={{
  body: `{
"root_url": "https://www.suaempresa.com",
"not_found_url": "https://www.suaempresa.com/ops"
}`,
}}
>
  Mande `""` para limpar um dos campos.
</Endpoint>

### Verificar o domínio agora

<Endpoint id="qr.domains-refresh" />

### Remover domínio

<Endpoint id="qr.domains-delete" />

## Comportamento do scan

Por padrão o scan é um redirect para a `target_url`. Estes campos, aceitos no `POST` e no `PATCH`, mudam o que acontece no scan. A avaliação é nesta ordem: **senha** (o scan só segue depois dela), **regras** (a primeira que casar vence), **teste A/B** (para os scans que nenhuma regra pegou) e, por fim, a `target_url`. Cada opção é cobrada uma vez (veja [Custos](#custos)).

Num `PATCH`, cada campo enviado **substitui** a sua parte: `[]` limpa as regras ou as variantes, `{}` limpa os pixels e `""` limpa uma URL ou a senha.

### Regras de redirect

`redirect_rules` (até 20) manda o scan para uma URL própria quando todas as condições da regra batem. Uma regra sem nenhuma condição é recusada.

| Campo                    | Significado                                                                                       |
| ------------------------ | ------------------------------------------------------------------------------------------------- |
| `id`                     | Nome da regra (`[a-zA-Z0-9_-]`, até 40); aparece nos scans e em `rule_stats` do analytics         |
| `target_url`             | Destino quando a regra casa (URL absoluta)                                                        |
| `countries`              | Países ISO 3166-1 alfa-2 em maiúsculas (`["BR", "PT"]`)                                           |
| `devices`                | `mobile`, `tablet`, `desktop`                                                                     |
| `os`                     | `ios`, `android`, `windows`, `macos`, `linux`, `other`                                            |
| `languages`              | Idioma preferido do visitante: `pt` casa `pt` e `pt-br`; `pt-br` só `pt-br`                       |
| `days`                   | Dias da semana, `0` (domingo) a `6` (sábado), no `timezone`                                       |
| `start_time`, `end_time` | Janela do dia (`HH:MM`); se o fim vier antes do início, atravessa a meia-noite. Os dois ou nenhum |
| `timezone`               | Fuso IANA (`America/Sao_Paulo`); UTC se vazio                                                     |
| `from`, `until`          | Período em que a regra vale (data e hora RFC 3339)                                                |

Cada lista aceita até 50 valores. Dentro de uma regra as condições se somam (E); dentro de uma lista, basta um valor (OU).

### Teste A/B

`ab_variants` (de 2 a 10) divide, por peso, os scans que nenhuma regra pegou. Cada variante tem `id` único, `target_url` e `weight` de 1 a 1000; a fatia de uma variante é o seu peso dividido pela soma. O analytics devolve `variant_stats` com os scans de cada variante.

### Senha

`password` (de 4 a 72 caracteres) esconde o QR atrás de uma página de senha. A senha é guardada com hash; as respostas trazem apenas `scan_behavior.has_password`. Depois de digitar a senha certa, o visitante segue o fluxo normal e essa página não fica em cache. Após 5 senhas erradas, o visitante fica bloqueado naquele QR por 15 minutos.

### Pixels

`pixels` dispara as tags numa página leve antes do redirect: `ga4_measurement_id` (`G-XXXXXXX`), `meta_pixel_id` (só dígitos) e `gtm_container_id` (`GTM-XXXXXX`).

### Redirect de expirado e esgotado

`expired_url` e `limit_reached_url` (URLs absolutas) substituem a página de QR indisponível quando o QR expirou (`expires_at`) ou usou todo o `scan_limit`.

### Exemplo completo

Copie, troque as URLs e rode:

```bash
curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/dynamic' \
  -H "X-API-Token: $RESPAWN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Campanha com regras",
    "target_url": "https://loja.example.com/global",
    "redirect_rules": [
      {
        "id": "br-mobile",
        "target_url": "https://loja.example.com/br",
        "countries": ["BR"],
        "devices": ["mobile"]
      },
      {
        "id": "horario-comercial",
        "target_url": "https://loja.example.com/atendimento",
        "days": [1, 2, 3, 4, 5],
        "start_time": "09:00",
        "end_time": "18:00",
        "timezone": "America/Sao_Paulo"
      }
    ],
    "ab_variants": [
      { "id": "a", "target_url": "https://loja.example.com/lp-a", "weight": 50 },
      { "id": "b", "target_url": "https://loja.example.com/lp-b", "weight": 50 }
    ],
    "pixels": { "ga4_measurement_id": "G-ABCD1234" },
    "expired_url": "https://loja.example.com/encerrada",
    "limit_reached_url": "https://loja.example.com/esgotado"
  }'
```

Para tirar tudo depois, sem recriar o QR (troque `$QR_ID` pelo `resource_id` da criação):

```bash
curl -sS -X PATCH "https://api.respawntech.dev/v1/tools/image/qr-code/dynamic/$QR_ID" \
  -H "X-API-Token: $RESPAWN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "redirect_rules": [], "ab_variants": [], "pixels": {}, "password": "" }'
```

`GET /{id}` devolve a configuração em `scan_behavior` (`redirect_rules`, `ab_variants`, `has_password`, `pixels`, `expired_url`, `limit_reached_url`, `content`).

## Conteúdo hospedado

Com `content`, o próprio `/r/{short_id}` serve uma página leve (ou um redirect calculado) no lugar da `target_url`. O `type` escolhe o formato e só o campo de mesmo nome é usado. Se uma regra de redirect ou um teste A/B casar, ele vale no lugar do conteúdo. As páginas não executam scripts e ficam em cache por 5 minutos (revalidadas por ETag). `{}` limpa o conteúdo num `PATCH`.

| `type`      | Campo                                                                                     | O scan mostra                                                                                                         |
| ----------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `vcard`     | `vcard` (`FirstName`, `LastName`, `Org`, `Title`, `Phone`, `Email`, `Website`, `Address`) | Página de contato e download do `.vcf` (`?format=vcf`). Exige nome ou organização                                     |
| `file`      | `file` (`url`, `name`)                                                                    | Abre o arquivo ou PDF hospedado (URL absoluta)                                                                        |
| `links`     | `links` (`title`, `description`, `avatar_url`, `links[]` com `label` e `url`)             | Link na bio, de 1 a 50 links                                                                                          |
| `menu`      | `menu` (`title`, `currency`, `sections[]` → `items[]` com `name`, `description`, `price`) | Cardápio, até 30 seções e 300 itens                                                                                   |
| `wifi`      | `wifi` (`SSID`, `Password`, `Security`, `Hidden`)                                         | Nome da rede e senha para copiar                                                                                      |
| `app_store` | `app_store` (`ios_url`, `android_url`, `fallback_url`)                                    | Redirect para a App Store ou o Google Play conforme o celular; `fallback_url` atende desktop e sistemas desconhecidos |

Os campos de `vcard` e `wifi` usam esses nomes em PascalCase, exatamente como na tabela; os demais usam `snake_case`. O `target_url` deixa de ser obrigatório. Ele assume o link do arquivo ou da loja e, nos tipos de página, cai na página de fallback da plataforma.

```bash
curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/dynamic' \
  -H "X-API-Token: $RESPAWN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Link na bio",
    "content": {
      "type": "links",
      "links": {
        "title": "Loja Exemplo",
        "links": [
          { "label": "Site", "url": "https://loja.example.com" },
          { "label": "Instagram", "url": "https://instagram.com/lojaexemplo" }
        ]
      }
    }
  }'
```

Exemplo de cartão de contato:

```bash
curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/dynamic' \
  -H "X-API-Token: $RESPAWN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Cartão da Ana",
    "content": {
      "type": "vcard",
      "vcard": { "FirstName": "Ana", "LastName": "Souza", "Org": "Loja Exemplo", "Phone": "+5511999990000", "Email": "ana@loja.example.com" }
    }
  }'
```

## Molduras e impressão

`frame` desenha uma chamada para ação em volta do código e `dpi` deixa a imagem pronta para impressão. Os dois valem para qualquer `response_types`.

* `frame`: `text` (até 32 caracteres; encolhe para caber), `color`, `text_color` (hex) e `position` (`top` ou `bottom`).
* `dpi`: de 72 a 1200. No **PDF** a página fica do tamanho exato do QR, em vetor; no **SVG** a largura e a altura saem em mm; o **PNG** carrega a resolução de impressão no arquivo. Sem `dpi`, o PDF mantém o layout A4.
* `eps`: formato vetorial novo, com a moldura e fonte Helvetica-Bold com acentos. Sai sem logo.

No PDF e no EPS, os olhos saem quadrados e só os estilos de módulo `dots` e quadrado viram vetor; o gradiente não é aplicado.

```bash
curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/dynamic' \
  -H "X-API-Token: $RESPAWN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Cartaz A5",
    "target_url": "https://loja.example.com/promo",
    "response_types": ["pdf", "eps"],
    "frame": { "text": "Escaneie aqui", "color": "#111111", "text_color": "#FFFFFF", "position": "bottom" },
    "dpi": 300
  }'
```

## Organização, edição em massa e lote

### Pastas e tags

`folder_id` guarda o QR numa pasta e `tags` (até 20 por QR, 40 caracteres cada, sem vírgula) o etiqueta; os dois entram no `POST` e no `PATCH` (`folder_id: ""` volta para a raiz, `tags: []` limpa). A listagem filtra por `folder_id`, `tags`, `status` e `search`.

### Listar pastas

<Endpoint id="qr.folders-list" />

### Criar pasta

<Endpoint id="qr.folders-create" example={{ body: '{ "name": "Campanha de verão" }' }}>
  O nome tem de 1 a 80 caracteres e é único na conta (`dynamic_qr.folder_taken`).
</Endpoint>

### Renomear pasta

<Endpoint id="qr.folders-rename" example={{ body: '{ "name": "Verão 2027" }' }} />

### Excluir pasta

<Endpoint id="qr.folders-delete" />

### Editar vários QRs de uma vez

<Endpoint
  id="qr.bulk"
  example={{
  body: `{
"ids": ["8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8"],
"action": "add_tags",
"tags": ["verao"]
}`,
}}
>
  Uma ação em até 500 QRs, de graça. `action` é `activate`, `deactivate`, `move` (com `folder_id`; `null` manda para a raiz), `set_target` (com `target_url`), `add_tags` ou `remove_tags` (com `tags`). A resposta traz o resultado de cada QR em `items`, então uma falha não derruba as demais. Aceita `X-Idempotency-Key`.
</Endpoint>

### Criar QRs em lote

<Endpoint
  id="qr.batches-create"
  example={{
  headers: { 'X-Idempotency-Key': '7f1c2e84-5b3a-4d6c-9e01-a2b3c4d5e6f7' },
  body: `{
"csv": "name,target_url,custom_slug,tags\\nLoja Centro,https://loja.example.com/centro,loja-centro,varejo|sp\\nLoja Norte,https://loja.example.com/norte,loja-norte,varejo",
"defaults": { "utm_forwarding": true }
}`,
}}
>
  Até 1.000 linhas por lote, de um destes jeitos:

  * JSON com `rows` (uma lista de objetos com os campos do QR) ou `csv` (o texto do CSV), mais `defaults` com os valores usados nas linhas que não os trazem (`folder_id`, `tags`, `scan_limit`, `expires_at`, `utm_forwarding`, cores, `custom_domain_id`);
  * corpo `text/csv` direto, com `folder_id` e `tags` como parâmetros de query.

  O CSV tem uma linha de cabeçalho e as colunas `name`, `target_url` (obrigatória), `custom_slug`, `folder_id`, `tags` (separadas por `|`), `scan_limit`, `expires_at`, `utm_forwarding`, `foreground_color` e `background_color`:

  ```csv
  name,target_url,custom_slug,tags
  Loja Centro,https://loja.example.com/centro,loja-centro,varejo|sp
  Loja Norte,https://loja.example.com/norte,loja-norte,varejo
  ```

  ```bash
  curl -sS -X POST 'https://api.respawntech.dev/v1/tools/image/qr-code/batches?tags=campanha' \
    -H "X-API-Token: $RESPAWN_API_TOKEN" \
    -H 'X-Idempotency-Key: 7f1c2e84-5b3a-4d6c-9e01-a2b3c4d5e6f7' \
    -H 'Content-Type: text/csv' \
    --data-binary @lojas.csv
  ```

  As linhas inválidas são reportadas e **não** são cobradas; as válidas são cobradas de uma vez, com desconto por volume (a partir de 100, 500 e 1.000 linhas), e criadas em segundo plano (`202`). Uma linha que falhar depois é estornada. Acompanhe consultando o detalhe do lote até `pending` chegar a 0. Com o mesmo `X-Idempotency-Key` e as mesmas linhas, o retry devolve o mesmo lote, cobrado uma vez. Acima do limite, a resposta é `413` (`dynamic_qr.batch_too_large`).
</Endpoint>

### Listar lotes

<Endpoint id="qr.batches-list" />

### Detalhe do lote

<Endpoint id="qr.batches-get">
  `items` traz cada linha (`row`, `status`, `qr_id`, `slug`, `full_url` ou `error_code` e `error_message`). `charged_units`, `discount_units` e `refunded_units` estão nas unidades internas de crédito (10.000 unidades = 1 crédito).
</Endpoint>

### Exportar scans em CSV

<Endpoint id="qr.scans-export" example={{ query: { period: 'last_30d' } }}>
  Query: `period` (`last_7d`, `last_30d` padrão, `last_90d` ou `all`) ou `from` e `to` (`YYYY-MM-DD`, UTC, inclusive), que substituem o período. Cobrada como o [histórico de scans](#qr-scans). Salve com `curl -o scans.csv`.
</Endpoint>

### Apagar o histórico de scans

<Endpoint id="qr.scans-purge" example={{ query: { before: '2026-01-01' } }}>
  Apaga as linhas de scan (todas, ou as anteriores a `before`, `YYYY-MM-DD` UTC). `total_scans` e `scan_limit` são mantidos. É irreversível.
</Endpoint>

## Erros

<ErrorTable tool="qr-dinamico" />

## Peculiaridades e limites

* URLs maliciosas são bloqueadas automaticamente (`dynamic_qr.malicious_url`).
* Proteção anti-loop: o destino não pode apontar para o próprio domínio de redirect.
* O primeiro QR não ocupa slot pago; os demais ocupam slots em grupos de 3.
* `skip_image_generation: true` reduz o custo quando você gera a imagem do QR por conta própria.
* Slug: 3–50 caracteres `[a-zA-Z0-9-]`. Listagens: até <Limit name="pageSizeMax" /> itens por página.
