# Enviar o SPED e receber o relatório de ICMS-ST

> Cadastrar a empresa, enviar o SPED, esperar a leitura pelo job, criar o relatório icms_st e guardar o resumo.

O caminho completo, do arquivo ao resumo em JSON: cadastrar a empresa, enviar o SPED, esperar a leitura, pedir o relatório e ler o resultado.

  Escopos empresas:escrever, arquivos:enviar, arquivos:ler, relatorios:criar, relatorios:ler

## 1. Cadastre a empresa

O CNPJ do SPED precisa ser de uma empresa da conta; senão o envio responde 422 [`empresa_nao_cadastrada`](/erros/empresa_nao_cadastrada).

```json title="POST /v1/empresas, com Idempotency-Key"
{ "cnpj": "11222333000181", "nome": "Empresa Exemplo Comércio Ltda" }
```

CNPJ que já está na conta responde 409 [`empresa_ja_cadastrada`](/erros/empresa_ja_cadastrada). Conta de parceiro informa também o `rede_id` da carteira.

## 2. Envie o arquivo e espere a leitura

`POST /v1/arquivos`, `multipart/form-data`, campo `arquivo`, um arquivo por chamada, com `Idempotency-Key`. Tipo, CNPJ e período saem do próprio arquivo. A resposta é `202` com o arquivo e o `Location` do job de leitura; acompanhe com `GET /v1/jobs/{id}`, **respeitando o `Retry-After`**, ou receba o [webhook](/guias/webhooks).

**Exemplos em 7 linguagens: Envio de arquivo com espera do job**

**cURL** (`envio.sh`)

```shell
#!/bin/sh
# Envia um SPED, espera o job de leitura (respeitando o Retry-After) e mostra o estado do arquivo.
# Precisa de RMF_API_URL (comece pelo sandbox) e RMF_API_KEY no ambiente. Requer curl e jq.
set -eu
: "${RMF_API_URL:?defina RMF_API_URL (sandbox: https://sandbox.api.rmfestrategia.com/v1)}"
: "${RMF_API_KEY:?defina RMF_API_KEY (chave rmf_test_ no sandbox)}"
ARQUIVO=${RMF_ARQUIVO:-efd-contribuicoes-11222333000181-2025-01.txt}

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# Um UUID por operação lógica; o MESMO UUID em todas as tentativas do envio.
chave=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)

espera() { awk -F': ' 'tolower($1)=="retry-after" {print int($2)}' "$tmp/cabecalhos"; }
get() { # get <caminho>: grava o corpo em $tmp/corpo e devolve o status; em 429, espera o Retry-After e repete
  for _ in 1 2 3 4 5; do
    s=$(curl -sS "$RMF_API_URL$1" -H "Authorization: Bearer $RMF_API_KEY" \
      -D "$tmp/cabecalhos" -o "$tmp/corpo" -w '%{http_code}')
    [ "$s" = 429 ] || break
    pausa=$(espera)
    sleep "${pausa:-1}"
  done
  echo "$s"
}
falha() { jq -r '"\(.codigo) (\(.request_id)): \(.detail)"' "$tmp/corpo" >&2; exit 1; }

status=0
for tentativa in 1 2 3 4 5; do
  status=$(curl -sS "$RMF_API_URL/arquivos" -H "Authorization: Bearer $RMF_API_KEY" \
    -H "Idempotency-Key: $chave" -F "arquivo=@$ARQUIVO" \
    -D "$tmp/cabecalhos" -o "$tmp/corpo" -w '%{http_code}')
  [ "$status" = 429 ] || break
  pausa=$(espera)
  sleep "${pausa:-1}"
done
[ "$status" -lt 400 ] || falha
arquivo_id=$(jq -r .id "$tmp/corpo")

# 202: a leitura segue em segundo plano e o job vem em Location. 200: o mesmo conteúdo já foi enviado.
job_id=$(awk -F': ' 'tolower($1)=="location" {sub(/\r$/, "", $2); n=split($2, p, "/"); print p[n]}' "$tmp/cabecalhos")
estado_job=-
if [ -n "$job_id" ]; then
  for _ in $(seq 1 90); do
    [ "$(get "/jobs/$job_id")" -lt 400 ] || falha
    estado_job=$(jq -r .estado "$tmp/corpo")
    case "$estado_job" in
      concluido | concluido_sem_dados | cancelado) break ;;
      falhou) jq -r '"falhou: \(.erro.codigo)"' "$tmp/corpo" >&2; exit 1 ;;
    esac
    pausa=$(espera)
    sleep "${pausa:-2}"
  done
fi

[ "$(get "/arquivos/$arquivo_id")" -lt 400 ] || falha
echo "arquivo=$arquivo_id estado=$(jq -r .estado "$tmp/corpo") job=$estado_job"
```

**TypeScript** (`envio.ts`)

```typescript
// Envia um SPED, espera o job de leitura (respeitando o Retry-After) e mostra o estado do arquivo.
// Node 22.18 ou mais novo. Precisa de RMF_API_URL e RMF_API_KEY.
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";

const BASE = process.env.RMF_API_URL;
const CHAVE = process.env.RMF_API_KEY;
if (!BASE || !CHAVE) {
  console.error("defina RMF_API_URL (sandbox: https://sandbox.api.rmfestrategia.com/v1) e RMF_API_KEY");
  process.exit(2);
}
const ARQUIVO = process.env.RMF_ARQUIVO ?? "efd-contribuicoes-11222333000181-2025-01.txt";
const TERMINAIS = ["concluido", "concluido_sem_dados", "falhou", "cancelado"];

const dormir = (s: number) => new Promise((r) => setTimeout(r, s * 1000));

async function chamar(caminho: string, init: RequestInit = {}): Promise<Response> {
  for (let tentativa = 1; ; tentativa++) {
    const resp = await fetch(`${BASE}${caminho}`, {
      ...init,
      headers: { Authorization: `Bearer ${CHAVE}`, ...init.headers },
    });
    // Nos GET, 429 espera o Retry-After e repete (o envio tem o seu laço, com a mesma Idempotency-Key).
    if (resp.status === 429 && !init.body && tentativa < 5) {
      await dormir(Number(resp.headers.get("Retry-After") ?? "1"));
      continue;
    }
    if (resp.status >= 400 && resp.status !== 429) {
      const erro = await resp.json(); // application/problem+json
      throw new Error(`${erro.codigo} (${erro.request_id}): ${erro.detail}`);
    }
    return resp;
  }
}

async function enviar(caminho: string): Promise<Response> {
  // Um UUID por operação lógica; o MESMO UUID em todas as tentativas do envio.
  const chave = randomUUID();
  const conteudo = await readFile(caminho);
  for (let tentativa = 1; tentativa <= 5; tentativa++) {
    const form = new FormData();
    form.append("arquivo", new Blob([conteudo]), caminho.split("/").pop());
    const resp = await chamar("/arquivos", { method: "POST", headers: { "Idempotency-Key": chave }, body: form });
    if (resp.status !== 429) return resp;
    await dormir(Number(resp.headers.get("Retry-After") ?? "1"));
  }
  throw new Error("limite_excedido: tente mais tarde");
}

const resp = await enviar(ARQUIVO);
const arquivo = await resp.json();

// 202: a leitura segue em segundo plano e o job vem em Location. 200: o mesmo conteúdo já foi enviado.
const location = resp.headers.get("Location");
let estadoJob = "-";
if (location) {
  for (let i = 0; i < 90; i++) {
    const job = await (await chamar(location.replace(/^\/v1/, ""))).json();
    estadoJob = job.estado;
    if (job.estado === "falhou") throw new Error(`falhou: ${job.erro?.codigo}`);
    if (TERMINAIS.includes(job.estado)) break;
    await dormir(Number(resp.headers.get("Retry-After") ?? "2"));
  }
}

const atual = await (await chamar(`/arquivos/${arquivo.id}`)).json();
console.log(`arquivo=${arquivo.id} estado=${atual.estado} job=${estadoJob}`);
```

**Python** (`envio.py`)

```python
"""Envia um SPED, espera o job de leitura (respeitando o Retry-After) e mostra o estado do arquivo.
Python 3.9 ou mais novo, com requests. Precisa de RMF_API_URL e RMF_API_KEY no ambiente."""
import os
import sys
import time
import uuid
from pathlib import Path

import requests

BASE = os.environ.get("RMF_API_URL")
CHAVE = os.environ.get("RMF_API_KEY")
if not BASE or not CHAVE:
    sys.exit("defina RMF_API_URL (sandbox: https://sandbox.api.rmfestrategia.com/v1) e RMF_API_KEY")
ARQUIVO = Path(os.environ.get("RMF_ARQUIVO") or "efd-contribuicoes-11222333000181-2025-01.txt")
TERMINAIS = {"concluido", "concluido_sem_dados", "falhou", "cancelado"}
HEADERS = {"Authorization": f"Bearer {CHAVE}"}


def conferir(resp: requests.Response) -> requests.Response:
    if resp.status_code >= 400:
        erro = resp.json()  # application/problem+json
        raise RuntimeError(f"{erro['codigo']} ({erro['request_id']}): {erro.get('detail')}")
    return resp


def obter(caminho: str) -> requests.Response:
    # GET com 429: espera o Retry-After e repete.
    for _ in range(5):
        resp = requests.get(BASE + caminho, headers=HEADERS, timeout=15)
        if resp.status_code != 429:
            return conferir(resp)
        time.sleep(int(resp.headers.get("Retry-After", "1")))
    raise RuntimeError("limite_excedido: tente mais tarde")


def enviar(caminho: Path) -> requests.Response:
    # Um UUID por operação lógica; o MESMO UUID em todas as tentativas do envio.
    chave = str(uuid.uuid4())
    for _ in range(5):
        with caminho.open("rb") as f:
            resp = requests.post(
                f"{BASE}/arquivos",
                headers={**HEADERS, "Idempotency-Key": chave},
                files={"arquivo": (caminho.name, f)},
                timeout=60,
            )
        if resp.status_code != 429:
            return conferir(resp)
        time.sleep(int(resp.headers.get("Retry-After", "1")))
    raise RuntimeError("limite_excedido: tente mais tarde")


resp = enviar(ARQUIVO)
arquivo = resp.json()

# 202: a leitura segue em segundo plano e o job vem em Location. 200: o mesmo conteúdo já foi enviado.
location = resp.headers.get("Location")
estado_job = "-"
if location:
    for _ in range(90):
        r = obter(location.removeprefix("/v1"))
        job = r.json()
        estado_job = job["estado"]
        if estado_job == "falhou":
            raise RuntimeError(f"falhou: {(job.get('erro') or {}).get('codigo')}")
        if estado_job in TERMINAIS:
            break
        time.sleep(int(r.headers.get("Retry-After", "2")))

atual = obter(f"/arquivos/{arquivo['id']}").json()
print(f"arquivo={arquivo['id']} estado={atual['estado']} job={estado_job}")
```

**PHP** (`envio.php`)

```php
<?php
// Envia um SPED, espera o job de leitura (respeitando o Retry-After) e mostra o estado do arquivo.
// PHP 8.1 ou mais novo, extensão curl. Precisa de RMF_API_URL e RMF_API_KEY no ambiente.
$base = getenv('RMF_API_URL');
$chave = getenv('RMF_API_KEY');
if (!$base || !$chave) {
    fwrite(STDERR, "defina RMF_API_URL (sandbox: https://sandbox.api.rmfestrategia.com/v1) e RMF_API_KEY\n");
    exit(2);
}
$arquivo = getenv('RMF_ARQUIVO') ?: 'efd-contribuicoes-11222333000181-2025-01.txt';
const TERMINAIS = ['concluido', 'concluido_sem_dados', 'falhou', 'cancelado'];

/** GET com 429: espera o Retry-After e repete. Os outros métodos (o envio tem o seu laço) seguem direto. */
function chamar(string $base, string $chave, string $metodo, string $caminho, array $extra = [], $corpo = null): array
{
    for ($tentativa = 1; ; $tentativa++) {
        $r = chamarUma($base, $chave, $metodo, $caminho, $extra, $corpo);
        if ($r[0] !== 429 || $metodo !== 'GET' || $tentativa >= 5) {
            return $r;
        }
        sleep((int) ($r[2]['retry-after'] ?? 1));
    }
}

/** Faz a chamada e devolve [status, corpo decodificado, cabeçalhos em minúsculas]. */
function chamarUma(string $base, string $chave, string $metodo, string $caminho, array $extra = [], $corpo = null): array
{
    $cab = [];
    $ch = curl_init($base . $caminho);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $metodo,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => array_merge(["Authorization: Bearer $chave"], $extra),
        CURLOPT_HEADERFUNCTION => function ($ch, $linha) use (&$cab) {
            if (str_contains($linha, ':')) {
                [$nome, $valor] = explode(':', $linha, 2);
                $cab[strtolower(trim($nome))] = trim($valor);
            }
            return strlen($linha);
        },
    ]);
    if ($corpo !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $corpo);
    }
    $texto = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $json = json_decode($texto, true);
    if ($status >= 400 && $status !== 429) { // application/problem+json
        throw new RuntimeException("{$json['codigo']} ({$json['request_id']}): {$json['detail']}");
    }
    return [$status, $json, $cab];
}

// Um UUID por operação lógica; o MESMO UUID em todas as tentativas do envio.
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
$idempotencia = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));

for ($tentativa = 1; $tentativa <= 5; $tentativa++) {
    [$status, $arq, $cab] = chamar($base, $chave, 'POST', '/arquivos', ["Idempotency-Key: $idempotencia"],
        ['arquivo' => new CURLFile($arquivo, 'text/plain', basename($arquivo))]);
    if ($status !== 429) {
        break;
    }
    sleep((int) ($cab['retry-after'] ?? 1));
}
if ($status === 429) {
    throw new RuntimeException('limite_excedido: tente mais tarde');
}

// 202: a leitura segue em segundo plano e o job vem em Location. 200: o mesmo conteúdo já foi enviado.
$estadoJob = '-';
if (isset($cab['location'])) {
    for ($i = 0; $i < 90; $i++) {
        [, $job, $cabJob] = chamar($base, $chave, 'GET', preg_replace('#^/v1#', '', $cab['location']));
        $estadoJob = $job['estado'];
        if ($estadoJob === 'falhou') {
            throw new RuntimeException('falhou: ' . ($job['erro']['codigo'] ?? ''));
        }
        if (in_array($estadoJob, TERMINAIS, true)) {
            break;
        }
        sleep((int) ($cabJob['retry-after'] ?? 2));
    }
}

[, $atual] = chamar($base, $chave, 'GET', '/arquivos/' . $arq['id']);
echo "arquivo={$arq['id']} estado={$atual['estado']} job=$estadoJob", PHP_EOL;
```

**C#** (`envio.cs`)

```csharp
// Envia um SPED, espera o job de leitura (respeitando o Retry-After) e mostra o estado do arquivo.
// .NET 8 ou mais novo, top-level statements. Precisa de RMF_API_URL e RMF_API_KEY no ambiente.
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;

var baseUrl = Environment.GetEnvironmentVariable("RMF_API_URL");
var chave = Environment.GetEnvironmentVariable("RMF_API_KEY");
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(chave))
{
    Console.Error.WriteLine("defina RMF_API_URL (sandbox: https://sandbox.api.rmfestrategia.com/v1) e RMF_API_KEY");
    return 2;
}
var arquivo = Environment.GetEnvironmentVariable("RMF_ARQUIVO") ?? "efd-contribuicoes-11222333000181-2025-01.txt";
string[] terminais = { "concluido", "concluido_sem_dados", "falhou", "cancelado" };

var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", chave);

// Um UUID por operação lógica; o MESMO UUID em todas as tentativas do envio.
var idempotencia = Guid.NewGuid().ToString();
HttpResponseMessage resp = null!;
for (var tentativa = 1; tentativa <= 5; tentativa++)
{
    using var form = new MultipartFormDataContent();
    form.Add(new ByteArrayContent(await File.ReadAllBytesAsync(arquivo)), "arquivo", Path.GetFileName(arquivo));
    using var req = new HttpRequestMessage(HttpMethod.Post, "arquivos") { Content = form };
    req.Headers.Add("Idempotency-Key", idempotencia);
    resp = await http.SendAsync(req);
    if (resp.StatusCode != HttpStatusCode.TooManyRequests) break;
    await Task.Delay(resp.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1));
}
var arq = await LerJson(resp);

// 202: a leitura segue em segundo plano e o job vem em Location. 200: o mesmo conteúdo já foi enviado.
var estadoJob = "-";
if (resp.Headers.Location is { } location)
{
    for (var i = 0; i < 90; i++)
    {
        using var r = await Obter(location.ToString().Replace("/v1/", ""));
        var job = await LerJson(r);
        estadoJob = job.GetProperty("estado").GetString();
        if (estadoJob == "falhou") throw new Exception($"falhou: {job.GetProperty("erro").GetProperty("codigo")}");
        if (terminais.Contains(estadoJob)) break;
        await Task.Delay(r.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(2));
    }
}

using var ra = await Obter($"arquivos/{arq.GetProperty("id").GetString()}");
var atual = await LerJson(ra);
Console.WriteLine($"arquivo={arq.GetProperty("id").GetString()} estado={atual.GetProperty("estado").GetString()} job={estadoJob}");
return 0;

async Task<HttpResponseMessage> Obter(string caminho) // GET com 429: espera o Retry-After e repete
{
    for (var tentativa = 1; tentativa <= 5; tentativa++)
    {
        var r = await http.GetAsync(caminho);
        if (r.StatusCode != HttpStatusCode.TooManyRequests) return r;
        await Task.Delay(r.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1));
        r.Dispose();
    }
    throw new Exception("limite_excedido: tente mais tarde");
}

async Task<JsonElement> LerJson(HttpResponseMessage r)
{
    using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync());
    var e = doc.RootElement.Clone();
    if ((int)r.StatusCode >= 400) // application/problem+json
        throw new Exception($"{e.GetProperty("codigo")} ({e.GetProperty("request_id")}): {e.GetProperty("detail")}");
    return e;
}
```

**Java** (`Envio.java`)

```java
// Envia um SPED, espera o job de leitura (respeitando o Retry-After) e mostra o estado do arquivo.
// Java 17 ou mais novo, com Jackson (com.fasterxml.jackson.core:jackson-databind).
// Precisa de RMF_API_URL e RMF_API_KEY no ambiente.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;

public class Envio {
    static final String BASE = System.getenv("RMF_API_URL");
    static final String CHAVE = System.getenv("RMF_API_KEY");
    static final List<String> TERMINAIS = List.of("concluido", "concluido_sem_dados", "falhou", "cancelado");
    static final HttpClient HTTP = HttpClient.newHttpClient();
    static final ObjectMapper JSON = new ObjectMapper();

    static HttpResponse<String> conferir(HttpResponse<String> resp) throws Exception {
        if (resp.statusCode() >= 400) { // application/problem+json
            var e = JSON.readTree(resp.body());
            throw new RuntimeException(e.path("codigo").asText() + " (" + e.path("request_id").asText()
                    + "): " + e.path("detail").asText());
        }
        return resp;
    }

    static HttpResponse<String> get(String caminho) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(BASE + caminho))
                .header("Authorization", "Bearer " + CHAVE).GET().build();
        for (int tentativa = 1; tentativa <= 5; tentativa++) {
            var resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
            if (resp.statusCode() != 429) return conferir(resp);
            Thread.sleep(espera(resp, 1) * 1000); // 429: espera o Retry-After e repete
        }
        throw new RuntimeException("limite_excedido: tente mais tarde");
    }

    static long espera(HttpResponse<?> resp, long padrao) {
        return Long.parseLong(resp.headers().firstValue("Retry-After").orElse(String.valueOf(padrao)));
    }

    static HttpResponse<String> enviar(Path arquivo) throws Exception {
        // Um UUID por operação lógica; o MESMO UUID em todas as tentativas do envio.
        var idempotencia = UUID.randomUUID().toString();
        var limite = "rmf" + UUID.randomUUID().toString().replace("-", "");
        var corpo = new ByteArrayOutputStream();
        corpo.write(("--" + limite + "\r\nContent-Disposition: form-data; name=\"arquivo\"; filename=\""
                + arquivo.getFileName() + "\"\r\nContent-Type: text/plain\r\n\r\n").getBytes(StandardCharsets.UTF_8));
        corpo.write(Files.readAllBytes(arquivo));
        corpo.write(("\r\n--" + limite + "--\r\n").getBytes(StandardCharsets.UTF_8));
        for (int tentativa = 1; tentativa <= 5; tentativa++) {
            var req = HttpRequest.newBuilder(URI.create(BASE + "/arquivos"))
                    .header("Authorization", "Bearer " + CHAVE)
                    .header("Idempotency-Key", idempotencia)
                    .header("Content-Type", "multipart/form-data; boundary=" + limite)
                    .POST(HttpRequest.BodyPublishers.ofByteArray(corpo.toByteArray())).build();
            var resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
            if (resp.statusCode() != 429) return conferir(resp);
            Thread.sleep(espera(resp, 1) * 1000);
        }
        throw new RuntimeException("limite_excedido: tente mais tarde");
    }

    public static void main(String[] args) throws Exception {
        if (BASE == null || CHAVE == null) {
            System.err.println("defina RMF_API_URL (sandbox: https://sandbox.api.rmfestrategia.com/v1) e RMF_API_KEY");
            System.exit(2);
        }
        var caminho = Path.of(System.getenv().getOrDefault("RMF_ARQUIVO", "efd-contribuicoes-11222333000181-2025-01.txt"));
        var resp = enviar(caminho);
        JsonNode arquivo = JSON.readTree(resp.body());

        // 202: a leitura segue em segundo plano e o job vem em Location. 200: o mesmo conteúdo já foi enviado.
        var estadoJob = "-";
        var location = resp.headers().firstValue("Location");
        if (location.isPresent()) {
            for (int i = 0; i < 90; i++) {
                var r = get(location.get().replaceFirst("^/v1", ""));
                var job = JSON.readTree(r.body());
                estadoJob = job.path("estado").asText();
                if (estadoJob.equals("falhou")) {
                    throw new RuntimeException("falhou: " + job.path("erro").path("codigo").asText());
                }
                if (TERMINAIS.contains(estadoJob)) break;
                Thread.sleep(espera(r, 2) * 1000);
            }
        }

        var atual = JSON.readTree(get("/arquivos/" + arquivo.path("id").asText()).body());
        System.out.println("arquivo=" + arquivo.path("id").asText() + " estado=" + atual.path("estado").asText()
                + " job=" + estadoJob);
    }
}
```

**Delphi** (`Envio.dpr`)

```pascal
program Envio;
// Envia um SPED, espera o job de leitura (respeitando o Retry-After) e mostra o estado do arquivo.
// Delphi 10.3 ou mais novo. Precisa de RMF_API_URL e RMF_API_KEY no ambiente.
// Exemplo escrito e revisado à mão: não roda no CI (não existe compilador Delphi em container).

{$APPTYPE CONSOLE}

uses
  System.SysUtils, System.Classes, System.JSON, System.Net.HttpClient, System.Net.URLClient,
  System.Net.Mime;

var
  Base, Chave, Caminho: string;
  Http: THTTPClient;

procedure Conferir(const AResp: IHTTPResponse);
var
  Erro: TJSONObject;
begin
  if AResp.StatusCode < 400 then Exit;
  Erro := TJSONObject.ParseJSONValue(AResp.ContentAsString(TEncoding.UTF8)) as TJSONObject; // problem+json
  try
    raise Exception.CreateFmt('%s (%s): %s', [Erro.GetValue<string>('codigo'),
      Erro.GetValue<string>('request_id'), Erro.GetValue<string>('detail')]);
  finally
    Erro.Free;
  end;
end;

function Enviar(const ACaminho: string): IHTTPResponse;
var
  Idempotencia: string;
  Form: TMultipartFormData;
  Tentativa: Integer;
begin
  // Um UUID por operação lógica; o MESMO UUID em todas as tentativas do envio.
  Idempotencia := GUIDToString(TGUID.NewGuid).Trim(['{', '}']).ToLower;
  for Tentativa := 1 to 5 do
  begin
    Form := TMultipartFormData.Create;
    try
      Form.AddFile('arquivo', ACaminho, 'text/plain');
      Result := Http.Post(Base + '/arquivos', Form, nil,
        [TNetHeader.Create('Idempotency-Key', Idempotencia)]);
    finally
      Form.Free;
    end;
    if Result.StatusCode <> 429 then
    begin
      Conferir(Result);
      Exit;
    end;
    Sleep(StrToIntDef(Result.HeaderValue['Retry-After'], 1) * 1000);
  end;
  raise Exception.Create('limite_excedido: tente mais tarde');
end;

function Obter(const ACaminho: string; out AResp: IHTTPResponse): TJSONObject;
var
  Tentativa: Integer;
begin
  for Tentativa := 1 to 5 do
  begin
    AResp := Http.Get(Base + ACaminho);
    if AResp.StatusCode <> 429 then Break;
    Sleep(StrToIntDef(AResp.HeaderValue['Retry-After'], 1) * 1000); // 429: espera o Retry-After e repete
  end;
  Conferir(AResp);
  Result := TJSONObject.ParseJSONValue(AResp.ContentAsString(TEncoding.UTF8)) as TJSONObject;
end;

var
  Resp, RJob: IHTTPResponse;
  Arquivo, Job, Atual: TJSONObject;
  Location, EstadoJob, ArquivoId: string;
  I: Integer;
begin
  Base := GetEnvironmentVariable('RMF_API_URL');
  Chave := GetEnvironmentVariable('RMF_API_KEY');
  if (Base = '') or (Chave = '') then
  begin
    Writeln(ErrOutput, 'defina RMF_API_URL (sandbox: https://sandbox.api.rmfestrategia.com/v1) e RMF_API_KEY');
    Halt(2);
  end;
  Caminho := GetEnvironmentVariable('RMF_ARQUIVO');
  if Caminho = '' then Caminho := 'efd-contribuicoes-11222333000181-2025-01.txt';

  Http := THTTPClient.Create;
  try
    Http.CustomHeaders['Authorization'] := 'Bearer ' + Chave;
    Resp := Enviar(Caminho);
    Arquivo := TJSONObject.ParseJSONValue(Resp.ContentAsString(TEncoding.UTF8)) as TJSONObject;
    try
      ArquivoId := Arquivo.GetValue<string>('id');
      // 202: a leitura segue em segundo plano e o job vem em Location. 200: o mesmo conteúdo já foi enviado.
      Location := Resp.HeaderValue['Location'];
      EstadoJob := '-';
      if Location <> '' then
        for I := 1 to 90 do
        begin
          Job := Obter(Location.Replace('/v1', '', []), RJob);
          try
            EstadoJob := Job.GetValue<string>('estado');
            if EstadoJob = 'falhou' then
              raise Exception.Create('falhou: ' + Job.GetValue<TJSONObject>('erro').GetValue<string>('codigo'));
          finally
            Job.Free;
          end;
          if (EstadoJob = 'concluido') or (EstadoJob = 'concluido_sem_dados') or (EstadoJob = 'cancelado') then
            Break;
          Sleep(StrToIntDef(RJob.HeaderValue['Retry-After'], 2) * 1000);
        end;

      Atual := Obter('/arquivos/' + ArquivoId, Resp);
      try
        Writeln('arquivo=', ArquivoId, ' estado=', Atual.GetValue<string>('estado'), ' job=', EstadoJob);
      finally
        Atual.Free;
      end;
    finally
      Arquivo.Free;
    end;
  finally
    Http.Free;
  end;
end.
```


- Mesmo conteúdo enviado de novo pela mesma conta: `200` com o arquivo existente e `duplicado: true`.
- Período já lido para o mesmo CNPJ: o arquivo termina em `periodo_ja_lido` e não é usado de novo.
- Uma retificadora substitui o arquivo anterior, que passa a `substituido`; o campo `substitui` do arquivo novo diz qual.
- Estados do arquivo: `recebido`, `na_fila`, `lendo`, `lido`, `periodo_ja_lido`, `substituido`, `rejeitado` (com o `motivo`).

No sandbox, o limite do arquivo é 20 MB e os resultados são fixos para os arquivos de exemplo. Baixe o [`efd-contribuicoes-11222333000181-2025-01.txt`](/exemplos-arquivos/efd-contribuicoes-11222333000181-2025-01.txt) sintético (a lista completa está em [Sandbox](/comecar/sandbox)).

## 3. Peça o relatório

Com o arquivo em `lido`, `POST /v1/relatorios` com `Idempotency-Key`:

```json title="Corpo da requisição"
{
  "tipo": "icms_st",
  "cnpj": "11222333000181",
  "periodo": { "inicio": "2025-01", "fim": "2025-01" },
  "opcoes": { "periodicidade_saida": "mensal" }
}
```

Os tipos disponíveis para a sua conta estão em `GET /v1/relatorios/tipos`; consulte-o em vez de fixar a lista no código. Se faltar o arquivo de uma competência, o relatório falha com `arquivo_faltando`. Neste lançamento os relatórios aceitam só empresa com CNPJ numérico (14 dígitos): um CNPJ alfanumérico responde 422 `parametro_invalido`. O relatório `planilha_sped` gera uma planilha só, do primeiro tipo de SPED com arquivo lido no período.

## 4. Leia o resumo

Espere o job (ou o evento `relatorio.concluido`) e leia `GET /v1/relatorios/{id}`. O resumo em JSON vem sempre; a planilha de resultado só aparece em `arquivos` quando o plano da conta a inclui, e se baixa com `GET /v1/relatorios/{id}/arquivos/{arquivo_id}`.

```json title="200 OK (recorte, valores ilustrativos)"
{
  "id": "rel_01JC8F6A9V1T4B7C2D5E8FGHJK",
  "tipo": "icms_st",
  "estado": "concluido",
  "resumo": {
    "total_credito": "18234.57",
    "por_competencia": [{ "competencia": "2026-01", "total_credito": "2011.40" }],
    "versao_base_aliquotas": "e964db7cb0e8"
  },
  "avisos": ["Há NCMs com alíquota de ICMS a confirmar no período. Confira esses NCMs em GET /icms/aliquotas e fale com a equipe da RMF antes de usar o crédito em apuração."]
}
```

Mostre os `avisos` ao usuário. Estados do relatório: `na_fila`, `processando`, `concluido`, `concluido_sem_dados`, `falhou` (com `erro.codigo`) e `cancelado`.
