Pular para o conteúdo
Softeum PedidosIntegração com o ERP

Exemplos prontos

Os arquivos da seção 7 do guia, para baixar e rodar: receptores do aviso, simuladores e clientes de busca pela API.

docs.softeum.com.br/exemplos/

Seções desta página

Exemplos prontos para os dois jeitos de receber os pedidos — o aviso (receptores e simuladores) e a busca pela API (clientes de busca). O guia completo é o docs/api.md; comece pelo kit-para-o-ti.md (o jeito de receber, o caminho de cada ERP, o teste, os acessos e quem testa o quê).

Em cada receptor e em cada cliente de busca, a única parte a escrever é a função que lança o pedido no ERP (lancarNoErp / lancar_no_erp). Agende a busca a cada 1 a 5 minutos.

O pedido de teste do painel ("teste": true, seção 8 do guia) só é lançado pelo cliente de busca rodado com SOFTEUM_HOMOLOGACAO=1, que você liga só no ERP de homologação. Sem ela, o cliente de busca recusa o pedido de teste com o motivo "Pedido de teste: não lançado no ERP de produção.", e ele sai da fila: o ERP de produção nunca o lança.

Testar em 1 minuto, com um receptor rodando na porta 8080:

node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_SEU_SEGREDO              # teste aprovado
node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_SEU_SEGREDO --pedido     # CONFIRMADO com o número ...
node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_SEU_SEGREDO --pedido     # o MESMO número (não duplicou)
node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_errado --pedido          # 401 e falha

Os receptores em Node.js, Python e PHP e os dois simuladores foram executados com esse roteiro em 25/10/2026; os dois clientes de busca, contra a API rodando localmente com 150 pedidos prontos (duas páginas, todos confirmados). O de C# segue o mesmo desenho, mas não foi executado (sem .NET no ambiente de teste). O pedido de exemplo do simulador e o vetor de teste do guia são conferidos contra o formato oficial pelo npm test (packages/schema/testes/guia.test.ts), e os dois clientes de busca rodam contra a API de verdade, com o pedido de teste na homologação e na produção (apps/api/testes/exemplos-de-busca.test.ts).

receptor.mjs

Endereço de entrega em Node.js 18+, sem dependências

Como rodar SOFTEUM_SEGREDO=whsec_... node receptor.mjs

// Exemplo de endereço de entrega do Softeum Pedidos, em Node.js (18 ou mais), sem dependências.
//
// Rodar:   SOFTEUM_SEGREDO=whsec_... node receptor.mjs
// Testar:  node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_... --pedido
//
// Em produção o endereço precisa ser HTTPS público: deixe este processo atrás do proxy ou balanceador
// que já faz o HTTPS do ERP (nginx, IIS, load balancer da nuvem).

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SEGREDO = process.env.SOFTEUM_SEGREDO;
const PORTA = Number(process.env.PORTA ?? 8080);
const TOLERANCIA_SEGUNDOS = 300;

if (!SEGREDO) {
  console.error("Defina SOFTEUM_SEGREDO com o segredo do painel (Integração → Como o ERP recebe → Aviso no endereço do ERP).");
  process.exit(1);
}

// pedido.id → número do pedido no ERP. No ERP de verdade isto é uma coluna do pedido de venda com
// restrição de unicidade (UNIQUE), não um Map em memória.
const lancados = new Map();

/** Confere o cabeçalho Softeum-Assinatura contra o corpo exatamente como chegou. */
function assinaturaValida(cabecalho, corpoBruto, segredo, agora = Math.floor(Date.now() / 1000)) {
  if (!cabecalho) return false;
  const partes = Object.fromEntries(cabecalho.split(",").map((p) => p.trim().split("=", 2)));
  const t = Number(partes.t);
  if (!Number.isInteger(t) || !partes.v1 || Math.abs(agora - t) > TOLERANCIA_SEGUNDOS) return false;
  const esperado = createHmac("sha256", segredo).update(`${t}.${corpoBruto}`, "utf8").digest("hex");
  const a = Buffer.from(esperado);
  const b = Buffer.from(partes.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

/** Aqui entra o lançamento de verdade no ERP. Devolve o número do pedido de venda criado. */
async function lancarNoErp(pedido) {
  // Exemplo: cliente por pedido.comprador.codigo_erp (ou CNPJ), itens por item.produto.codigo_erp,
  // quantidade e preço na unidade do comprador (item.quantidade, item.unidade, item.preco_unitario).
  console.log(`Lançando o pedido ${pedido.numero_comprador} de ${pedido.comprador.razao_social}: ${pedido.itens.length} item(ns), R$ ${pedido.valor_total}`);
  return String(500000 + lancados.size + 1);
}

function responder(res, status, corpo) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(corpo));
}

createServer((req, res) => {
  if (req.method !== "POST") return responder(res, 405, { erro: "Use POST." });

  const partes = [];
  req.on("data", (parte) => partes.push(parte));
  req.on("end", async () => {
    // 1. O corpo BRUTO: a assinatura é calculada sobre os bytes que chegaram, antes de qualquer JSON.parse.
    const corpo = Buffer.concat(partes).toString("utf8");
    if (!assinaturaValida(req.headers["softeum-assinatura"], corpo, SEGREDO)) {
      return responder(res, 401, { erro: "Assinatura inválida." });
    }

    let aviso;
    try {
      aviso = JSON.parse(corpo);
    } catch {
      return responder(res, 400, { erro: "JSON inválido." });
    }

    // 2. Aviso de teste (botão "Enviar teste") e tipos que o ERP ainda não conhece: só 2xx, nada lançado.
    if (aviso.tipo !== "pedido.aprovado") return responder(res, 200, {});

    // 3. Pedido repetido: não lança de novo, devolve o mesmo número.
    const pedido = aviso.pedido;
    const existente = lancados.get(pedido.id);
    if (existente) return responder(res, 200, { numero_erp: existente });

    // 4. Lança e devolve o número: o pedido fica "confirmado" no Softeum na hora.
    try {
      const numeroErp = await lancarNoErp(pedido);
      lancados.set(pedido.id, numeroErp);
      return responder(res, 200, { numero_erp: numeroErp });
    } catch (erro) {
      // Qualquer código fora de 2xx faz o Softeum tentar de novo depois (1, 5, 30, 120 e 360 minutos).
      console.error("Não deu para lançar:", erro);
      return responder(res, 503, { erro: "Não deu para lançar agora." });
    }
  });
}).listen(PORTA, () => console.log(`Esperando avisos do Softeum em http://localhost:${PORTA}`));

receptor.py

O mesmo em Python 3.9+, só biblioteca padrão

Como rodar SOFTEUM_SEGREDO=whsec_... python3 receptor.py

# Exemplo de endereço de entrega do Softeum Pedidos, em Python 3.9 ou mais, só com a biblioteca padrão.
#
# Rodar:   SOFTEUM_SEGREDO=whsec_... python3 receptor.py
# Testar:  node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_... --pedido
#
# Em produção o endereço precisa ser HTTPS público: deixe este processo atrás do proxy ou balanceador
# que já faz o HTTPS do ERP. Em Flask/Django/FastAPI a lógica é a mesma: use o corpo BRUTO
# (request.get_data(), request.body, await request.body()), nunca o JSON já convertido.

import hashlib
import hmac
import json
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SEGREDO = os.environ.get("SOFTEUM_SEGREDO")
PORTA = int(os.environ.get("PORTA", "8080"))
TOLERANCIA_SEGUNDOS = 300

if not SEGREDO:
    sys.exit("Defina SOFTEUM_SEGREDO com o segredo do painel (Integração → Como o ERP recebe → Aviso no endereço do ERP).")

# pedido.id → número do pedido no ERP. No ERP de verdade: coluna do pedido de venda com UNIQUE.
lancados: dict[str, str] = {}


def assinatura_valida(cabecalho, corpo_bruto: bytes, segredo: str, agora=None) -> bool:
    """Confere o cabeçalho Softeum-Assinatura contra o corpo exatamente como chegou."""
    if not cabecalho:
        return False
    partes = dict(p.strip().split("=", 1) for p in cabecalho.split(",") if "=" in p)
    try:
        t = int(partes["t"])
    except (KeyError, ValueError):
        return False
    agora = int(time.time()) if agora is None else agora
    if abs(agora - t) > TOLERANCIA_SEGUNDOS or "v1" not in partes:
        return False
    esperado = hmac.new(segredo.encode(), f"{t}.".encode() + corpo_bruto, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, partes["v1"])


def lancar_no_erp(pedido: dict) -> str:
    """Aqui entra o lançamento de verdade no ERP. Devolve o número do pedido de venda criado."""
    print(f"Lançando o pedido {pedido['numero_comprador']} de {pedido['comprador']['razao_social']}: "
          f"{len(pedido['itens'])} item(ns), R$ {pedido['valor_total']}")
    return str(500000 + len(lancados) + 1)


class Receptor(BaseHTTPRequestHandler):
    def responder(self, status: int, corpo: dict):
        dados = json.dumps(corpo).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(dados)))
        self.end_headers()
        self.wfile.write(dados)

    def do_POST(self):
        # 1. O corpo BRUTO: a assinatura é calculada sobre os bytes que chegaram.
        corpo = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not assinatura_valida(self.headers.get("Softeum-Assinatura"), corpo, SEGREDO):
            return self.responder(401, {"erro": "Assinatura inválida."})
        try:
            aviso = json.loads(corpo)
        except ValueError:
            return self.responder(400, {"erro": "JSON inválido."})

        # 2. Aviso de teste e tipos que o ERP ainda não conhece: só 2xx, nada lançado.
        if aviso.get("tipo") != "pedido.aprovado":
            return self.responder(200, {})

        # 3. Pedido repetido: não lança de novo, devolve o mesmo número.
        pedido = aviso["pedido"]
        if pedido["id"] in lancados:
            return self.responder(200, {"numero_erp": lancados[pedido["id"]]})

        # 4. Lança e devolve o número: o pedido fica "confirmado" no Softeum na hora.
        try:
            numero_erp = lancar_no_erp(pedido)
        except Exception as erro:  # fora de 2xx, o Softeum tenta de novo depois
            print("Não deu para lançar:", erro, file=sys.stderr)
            return self.responder(503, {"erro": "Não deu para lançar agora."})
        lancados[pedido["id"]] = numero_erp
        return self.responder(200, {"numero_erp": numero_erp})


if __name__ == "__main__":
    print(f"Esperando avisos do Softeum em http://localhost:{PORTA}")
    ThreadingHTTPServer(("", PORTA), Receptor).serve_forever()

receptor.php

O mesmo em PHP 8, guardando os lançados em SQLite

Como rodar SOFTEUM_SEGREDO=whsec_... php -S localhost:8080 receptor.php

<?php
// Exemplo de endereço de entrega do Softeum Pedidos, em PHP 8, sem dependências (usa SQLite para lembrar os
// pedidos já lançados; no ERP de verdade é a tabela de pedidos de venda, com UNIQUE no id do Softeum).
//
// Rodar:   SOFTEUM_SEGREDO=whsec_... php -S localhost:8080 receptor.php
// Testar:  node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_... --pedido
//
// Em produção: atrás do Apache/nginx/IIS com HTTPS público.

const TOLERANCIA_SEGUNDOS = 300;

function responder(int $status, array $corpo): void {
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($corpo ?: new stdClass());
    exit;
}

/** Confere o cabeçalho Softeum-Assinatura contra o corpo exatamente como chegou. */
function assinatura_valida(?string $cabecalho, string $corpoBruto, string $segredo, ?int $agora = null): bool {
    if (!$cabecalho) return false;
    $partes = [];
    foreach (explode(',', $cabecalho) as $p) {
        [$k, $v] = array_pad(explode('=', trim($p), 2), 2, null);
        $partes[$k] = $v;
    }
    if (!isset($partes['t'], $partes['v1']) || !ctype_digit($partes['t'])) return false;
    $t = (int) $partes['t'];
    if (abs(($agora ?? time()) - $t) > TOLERANCIA_SEGUNDOS) return false;
    $esperado = hash_hmac('sha256', $t . '.' . $corpoBruto, $segredo);
    return hash_equals($esperado, $partes['v1']);
}

/** Aqui entra o lançamento de verdade no ERP. Devolve o número do pedido de venda criado. */
function lancar_no_erp(array $pedido, PDO $banco): string {
    error_log(sprintf('Lançando o pedido %s de %s: %d item(ns), R$ %s',
        $pedido['numero_comprador'], $pedido['comprador']['razao_social'], count($pedido['itens']), $pedido['valor_total']));
    return (string) (500000 + (int) $banco->query('select count(*) from lancados')->fetchColumn() + 1);
}

$segredo = getenv('SOFTEUM_SEGREDO');
if (!$segredo) responder(500, ['erro' => 'Defina SOFTEUM_SEGREDO.']);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') responder(405, ['erro' => 'Use POST.']);

// 1. O corpo BRUTO: a assinatura é calculada sobre os bytes que chegaram, antes do json_decode.
$corpo = file_get_contents('php://input');
if (!assinatura_valida($_SERVER['HTTP_SOFTEUM_ASSINATURA'] ?? null, $corpo, $segredo)) {
    responder(401, ['erro' => 'Assinatura inválida.']);
}
$aviso = json_decode($corpo, true);
if (!is_array($aviso)) responder(400, ['erro' => 'JSON inválido.']);

// 2. Aviso de teste e tipos que o ERP ainda não conhece: só 2xx, nada lançado.
if (($aviso['tipo'] ?? '') !== 'pedido.aprovado') responder(200, []);

$banco = new PDO('sqlite:' . __DIR__ . '/lancados.sqlite');
$banco->exec('create table if not exists lancados (pedido_id text primary key, numero_erp text not null)');

// 3. Pedido repetido: não lança de novo, devolve o mesmo número.
$pedido = $aviso['pedido'];
$busca = $banco->prepare('select numero_erp from lancados where pedido_id = ?');
$busca->execute([$pedido['id']]);
if ($existente = $busca->fetchColumn()) responder(200, ['numero_erp' => $existente]);

// 4. Lança e devolve o número: o pedido fica "confirmado" no Softeum na hora.
try {
    $numero = lancar_no_erp($pedido, $banco);
    $banco->prepare('insert into lancados (pedido_id, numero_erp) values (?, ?)')->execute([$pedido['id'], $numero]);
    responder(200, ['numero_erp' => $numero]);
} catch (Throwable $erro) {
    error_log('Não deu para lançar: ' . $erro->getMessage());
    responder(503, ['erro' => 'Não deu para lançar agora.']); // fora de 2xx, o Softeum tenta de novo depois
}

Program.cs

O mesmo em C# (.NET 8, minimal API)

Como rodar dotnet new web, troque o Program.cs, dotnet run

// Exemplo de endereço de entrega do Softeum Pedidos, em C# (.NET 8, ASP.NET Core minimal API).
//
// Criar:   dotnet new web -n ReceptorSofteum   e troque o Program.cs gerado por este arquivo.
// Rodar:   SOFTEUM_SEGREDO=whsec_... dotnet run        (no Windows: set SOFTEUM_SEGREDO=whsec_...)
// Testar:  node simular-aviso.mjs http://localhost:8080/softeum/pedidos whsec_... --pedido
//
// Em produção: atrás do IIS/Kestrel com HTTPS público. Este exemplo não foi executado no ambiente da Softeum
// (os de Node, Python e PHP foram); a conferência da assinatura é a mesma dos outros três.

using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var segredo = Environment.GetEnvironmentVariable("SOFTEUM_SEGREDO")
    ?? throw new InvalidOperationException("Defina SOFTEUM_SEGREDO com o segredo do painel (Integração → Como o ERP recebe → Aviso no endereço do ERP).");
var porta = Environment.GetEnvironmentVariable("PORTA") ?? "8080";

// pedido.id → número do pedido no ERP. No ERP de verdade: coluna do pedido de venda com UNIQUE.
var lancados = new ConcurrentDictionary<string, string>();

var app = WebApplication.Create(args);

app.MapPost("/softeum/pedidos", async (HttpRequest req) =>
{
    // 1. O corpo BRUTO: a assinatura é calculada sobre os bytes que chegaram, antes de desserializar.
    using var leitor = new StreamReader(req.Body, Encoding.UTF8);
    var corpo = await leitor.ReadToEndAsync();
    if (!AssinaturaValida(req.Headers["Softeum-Assinatura"].ToString(), corpo, segredo))
        return Results.Json(new { erro = "Assinatura inválida." }, statusCode: 401);

    JsonElement aviso;
    try { aviso = JsonDocument.Parse(corpo).RootElement; }
    catch (JsonException) { return Results.Json(new { erro = "JSON inválido." }, statusCode: 400); }

    // 2. Aviso de teste e tipos que o ERP ainda não conhece: só 2xx, nada lançado.
    if (!aviso.TryGetProperty("tipo", out var tipo) || tipo.GetString() != "pedido.aprovado")
        return Results.Json(new { });

    // 3. Pedido repetido: não lança de novo, devolve o mesmo número.
    var pedido = aviso.GetProperty("pedido");
    var id = pedido.GetProperty("id").GetString()!;
    if (lancados.TryGetValue(id, out var existente))
        return Results.Json(new { numero_erp = existente });

    // 4. Lança e devolve o número: o pedido fica "confirmado" no Softeum na hora.
    try
    {
        var numero = LancarNoErp(pedido, lancados.Count);
        lancados[id] = numero;
        return Results.Json(new { numero_erp = numero });
    }
    catch (Exception erro)
    {
        Console.Error.WriteLine($"Não deu para lançar: {erro.Message}");
        return Results.Json(new { erro = "Não deu para lançar agora." }, statusCode: 503); // o Softeum tenta de novo depois
    }
});

Console.WriteLine($"Esperando avisos do Softeum em http://localhost:{porta}");
app.Run($"http://0.0.0.0:{porta}");

// Aqui entra o lançamento de verdade no ERP. Devolve o número do pedido de venda criado.
static string LancarNoErp(JsonElement pedido, int jaLancados)
{
    Console.WriteLine($"Lançando o pedido {pedido.GetProperty("numero_comprador").GetString()} " +
                      $"de {pedido.GetProperty("comprador").GetProperty("razao_social").GetString()}: " +
                      $"{pedido.GetProperty("itens").GetArrayLength()} item(ns), R$ {pedido.GetProperty("valor_total").GetDecimal()}");
    return (500000 + jaLancados + 1).ToString();
}

// Confere o cabeçalho Softeum-Assinatura contra o corpo exatamente como chegou.
static bool AssinaturaValida(string? cabecalho, string corpoBruto, string segredo, long? agora = null)
{
    if (string.IsNullOrEmpty(cabecalho)) return false;
    var partes = new Dictionary<string, string>();
    foreach (var parte in cabecalho.Split(','))
    {
        var kv = parte.Trim().Split('=', 2);
        if (kv.Length == 2) partes[kv[0]] = kv[1];
    }
    if (!partes.TryGetValue("t", out var tTexto) || !long.TryParse(tTexto, out var t)) return false;
    if (!partes.TryGetValue("v1", out var v1)) return false;
    var now = agora ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(now - t) > 300) return false;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(segredo));
    var esperado = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{corpoBruto}"))).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(esperado), Encoding.UTF8.GetBytes(v1));
}

simular-aviso.mjs

Simulador: manda um aviso assinado igual ao do Softeum

Como rodar node simular-aviso.mjs <endereço> <segredo> [--pedido] [--novo]

// Simulador do aviso do Softeum Pedidos: manda ao seu endereço um aviso assinado igual ao de produção.
// Serve para testar o endereço de entrega do ERP sem depender do painel. Node.js 18 ou mais, sem dependências.
//
//   node simular-aviso.mjs <endereço> <segredo>               aviso de teste (não deve lançar nada)
//   node simular-aviso.mjs <endereço> <segredo> --pedido      pedido de exemplo (sempre o mesmo id: rode duas
//                                                            vezes e confira que o ERP não lança duas vezes)
//   node simular-aviso.mjs <endereço> <segredo> --pedido --novo   pedido de exemplo com um id novo
//
// Exemplo: node simular-aviso.mjs https://erp.suaempresa.com.br/softeum/pedidos whsec_... --pedido

import { createHmac, randomUUID } from "node:crypto";

const [endereco, segredo, ...opcoes] = process.argv.slice(2);
if (!endereco || !segredo) {
  console.error("Uso: node simular-aviso.mjs <endereço> <segredo> [--pedido] [--novo]");
  process.exit(1);
}

/** Data e hora no fuso de Brasília, no formato do contrato (2026-09-30T16:42:05-03:00). */
const agoraComFuso = () => new Date(Date.now() - 3 * 3600 * 1000).toISOString().slice(0, 19) + "-03:00";

const PEDIDO_EXEMPLO = {
  id: "0199a3c4-5f10-7a2b-8c3d-4e5f60718293",
  numero_comprador: "45882",
  situacao: "pronto",
  origem: "pdf",
  recebido_em: "2026-09-30T16:13:00-03:00",
  aprovado_em: "2026-09-30T16:42:00-03:00",
  comprador: { cnpj: "12345678000195", razao_social: "Distribuidora Rio Claro Ltda", codigo_erp: "C001" },
  vendedor: { nome: "Carlos Menezes", email: "carlos.menezes@valeazul.com.br", codigo_erp: null },
  condicao_pagamento: "28/42/56 dias",
  data_pedido: "2026-09-29",
  data_entrega: "2026-10-07",
  endereco_entrega: "CD Rio Claro, Av. Brasil, 2150",
  observacoes: null,
  itens: [
    {
      linha: 4,
      codigo_comprador: "004359",
      descricao_comprador: "LIMPA VIDROS REFIL 500ML CX12",
      produto: { sku: "VA-LVD-500R", codigo_erp: "4359", descricao: "Limpa-vidros refil 500 ml", ean: "7896543000415" },
      quantidade: 30,
      unidade: "CX",
      quantidade_erp: 360,
      unidade_erp: "UN",
      preco_unitario: 76.04,
      valor_total: 2281.2,
      data_entrega: null,
    },
  ],
  valor_total: 2281.2,
  revisao: { revisado_por: "Marina Costa", revisado_em: "2026-09-30T16:40:00-03:00", motivos: ["preco"] },
  numero_erp: null,
  arquivo_original: "https://arquivo-indisponivel.softeum.com.br/",
};

const comPedido = opcoes.includes("--pedido");
const pedido = { ...PEDIDO_EXEMPLO, id: opcoes.includes("--novo") ? randomUUID() : PEDIDO_EXEMPLO.id };
const aviso = comPedido
  ? { id: randomUUID(), tipo: "pedido.aprovado", criado_em: agoraComFuso(), pedido }
  : { id: randomUUID(), tipo: "teste", criado_em: agoraComFuso() };

const corpo = JSON.stringify(aviso);
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac("sha256", segredo).update(`${t}.${corpo}`, "utf8").digest("hex");

const inicio = Date.now();
try {
  const resposta = await fetch(endereco, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "User-Agent": "Softeum-Pedidos (simulador)",
      "Softeum-Evento": aviso.tipo,
      "Softeum-Entrega": aviso.id,
      "Softeum-Assinatura": `t=${t},v1=${v1}`,
    },
    body: corpo,
    redirect: "manual", // o Softeum não segue redirecionamento: 3xx conta como falha
    signal: AbortSignal.timeout(10_000), // o mesmo prazo do Softeum
  });
  const texto = (await resposta.text()).slice(0, 4000);
  const ms = Date.now() - inicio;
  console.log(`Aviso ${aviso.tipo}${comPedido ? ` do pedido ${pedido.id}` : ""}`);
  console.log(`Resposta: ${resposta.status} em ${ms} ms`);
  if (texto) console.log(`Corpo: ${texto}`);

  let numero = null;
  try {
    const json = JSON.parse(texto);
    numero = typeof json.numero_erp === "string" || typeof json.numero_erp === "number" ? String(json.numero_erp) : null;
  } catch {}
  const sucesso = resposta.status >= 200 && resposta.status < 300;
  if (!sucesso) console.log("→ No Softeum: falha. O aviso seria reenviado em 1, 5, 30, 120 e 360 minutos.");
  else if (!comPedido) console.log("→ No Softeum: teste aprovado.");
  else if (numero) console.log(`→ No Softeum: pedido CONFIRMADO com o número ${numero}.`);
  else console.log("→ No Softeum: pedido ENVIADO (sem numero_erp na resposta).");
} catch (erro) {
  const prazo = erro?.name === "TimeoutError";
  console.log(prazo ? "Sem resposta em 10 s." : `Não deu para chegar ao endereço: ${erro?.cause?.message ?? erro?.message}`);
  console.log("→ No Softeum: falha. O aviso seria reenviado em 1, 5, 30, 120 e 360 minutos.");
  process.exitCode = 1;
}

simular-aviso.sh

Simulador do aviso de teste com curl e openssl

Como rodar ./simular-aviso.sh <endereço> <segredo>

#!/usr/bin/env bash
# Simulador do aviso de teste do Softeum Pedidos, só com curl e openssl (Linux, macOS ou Git Bash no Windows).
# Manda ao seu endereço um aviso "teste" assinado exatamente como o Softeum assina.
#
#   ./simular-aviso.sh https://erp.suaempresa.com.br/softeum/pedidos whsec_...
#
# Para simular um pedido completo, use simular-aviso.mjs (Node.js).

set -euo pipefail

ENDERECO="${1:?Uso: ./simular-aviso.sh <endereço> <segredo>}"
SEGREDO="${2:?Uso: ./simular-aviso.sh <endereço> <segredo>}"

ID="0199a3c4-5f10-7a2b-8c3d-$(printf '%012d' "$(date +%s)")"
CRIADO_EM="$(TZ=America/Sao_Paulo date +%Y-%m-%dT%H:%M:%S)-03:00"
CORPO="{\"id\":\"$ID\",\"tipo\":\"teste\",\"criado_em\":\"$CRIADO_EM\"}"

# A assinatura: HMAC-SHA256 de "<t>.<corpo>" com o segredo inteiro (com o prefixo whsec_), em hexadecimal.
T="$(date +%s)"
V1="$(printf '%s.%s' "$T" "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO" -hex | sed 's/^.*= *//')"

echo "Enviando: $CORPO"
curl -sS -X POST "$ENDERECO" \
  --max-time 10 \
  -H "Content-Type: application/json" \
  -H "User-Agent: Softeum-Pedidos (simulador)" \
  -H "Softeum-Evento: teste" \
  -H "Softeum-Entrega: $ID" \
  -H "Softeum-Assinatura: t=$T,v1=$V1" \
  --data-binary "$CORPO" \
  -w '\nCódigo da resposta: %{http_code} em %{time_total} s (2xx = teste aprovado)\n'

buscar-pedidos.mjs

Busca pela API em Node.js 18+: lista os prontos, lança, confirma ou recusa

Como rodar SOFTEUM_CHAVE=sp_live_... node buscar-pedidos.mjs

// Exemplo de busca dos pedidos pela API do Softeum Pedidos, em Node.js (18 ou mais), sem dependências.
// Para o ERP que não recebe o aviso (sem endereço HTTPS público): ele busca os pedidos prontos, lança e confirma.
//
// Rodar:   SOFTEUM_CHAVE=sp_live_... node buscar-pedidos.mjs
//          (agende a cada 1 a 5 minutos: cron, Agendador de Tarefas do Windows, job do ERP)
// Outro endereço (homologação): SOFTEUM_API=https://... node buscar-pedidos.mjs
// No ERP de testes (homologação): SOFTEUM_HOMOLOGACAO=1 SOFTEUM_CHAVE=sp_live_... node buscar-pedidos.mjs
//          Só com ela o pedido de teste do painel ("teste": true) é lançado. Sem ela (o ERP de produção), ele é
//          recusado com o motivo e sai da fila: o ERP de produção nunca lança um pedido de teste.

const API = process.env.SOFTEUM_API ?? "https://api.softeum.com.br/v1";
const CHAVE = process.env.SOFTEUM_CHAVE;
const HOMOLOGACAO = process.env.SOFTEUM_HOMOLOGACAO === "1";

if (!CHAVE) {
  console.error("Defina SOFTEUM_CHAVE com a chave de acesso do painel (Integração → Como o ERP recebe → O ERP busca os pedidos).");
  process.exit(1);
}

/** Chamada à API. Erros vêm em application/problem+json (title, detail, erros[]). */
async function chamar(metodo, caminho, corpo) {
  for (let tentativa = 1; ; tentativa++) {
    const resposta = await fetch(`${API}${caminho}`, {
      method: metodo,
      headers: { Authorization: `Bearer ${CHAVE}`, ...(corpo ? { "Content-Type": "application/json" } : {}) },
      body: corpo ? JSON.stringify(corpo) : undefined,
      signal: AbortSignal.timeout(30_000),
    });
    // Muitas chamadas: espera o que o Retry-After pede e tenta de novo.
    if (resposta.status === 429 && tentativa <= 3) {
      await new Promise((r) => setTimeout(r, Number(resposta.headers.get("retry-after") ?? 10) * 1000));
      continue;
    }
    const json = await resposta.json().catch(() => null);
    return { status: resposta.status, json };
  }
}

/**
 * Aqui entra o lançamento de verdade no ERP. Devolve o número do pedido de venda criado.
 * Lance de forma idempotente: se pedido.id já foi lançado, devolva o número existente (UNIQUE no id).
 * Para recusar (cliente bloqueado, condição que não existe), lance um erro com { recusa: "motivo" }.
 */
async function lancarNoErp(pedido) {
  console.log(`Lançando o pedido ${pedido.numero_comprador} de ${pedido.comprador.razao_social}: ${pedido.itens.length} item(ns), R$ ${pedido.valor_total}`);
  return String(500000 + Math.floor(Math.random() * 1000));
}

/** Recusa o pedido com o motivo: ele sai de "pronto" e o painel mostra o motivo ao time. */
async function recusar(pedido, motivo) {
  const r = await chamar("POST", `/pedidos/${pedido.id}/recusa`, { motivo });
  console.log(r.status === 200 ? `  recusado: ${motivo}` : `  recusa respondeu ${r.status}: ${r.json?.detail}`);
}

async function rodada() {
  // Cada rodada começa do início da fila de prontos (sem cursor): o pedido sai de "pronto" quando é confirmado ou
  // recusado, então o que ficou para trás aparece de novo. O cursor serve só para ler as páginas da mesma rodada.
  let cursor = null;
  let processados = 0;
  do {
    const consulta = `/pedidos?situacao=pronto&limite=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
    const { status, json } = await chamar("GET", consulta);
    if (status !== 200) throw new Error(`A lista respondeu ${status}: ${json?.detail ?? json?.title ?? ""}`);

    for (const pedido of json.dados) {
      // O pedido de teste do painel (seção 8 do guia) só entra no ERP de testes: no de produção, sai da fila recusado.
      if (pedido.teste === true && !HOMOLOGACAO) {
        console.log(`Pedido de teste ${pedido.numero_comprador}: não é lançado no ERP de produção.`);
        await recusar(pedido, "Pedido de teste: não lançado no ERP de produção.");
        processados++;
        continue;
      }
      try {
        const numeroErp = await lancarNoErp(pedido);
        const r = await chamar("POST", `/pedidos/${pedido.id}/confirmacao`, { numero_erp: numeroErp });
        console.log(r.status === 200 ? `  confirmado como ${numeroErp}` : `  confirmação respondeu ${r.status}: ${r.json?.detail}`);
      } catch (erro) {
        if (erro?.recusa) {
          await recusar(pedido, erro.recusa);
        } else {
          // Falha do ERP (fora do ar, travado): o pedido continua pronto e volta na próxima rodada.
          console.error(`  não deu para lançar agora: ${erro?.message ?? erro}`);
        }
      }
      processados++;
    }
    cursor = json.proximo_cursor;
  } while (cursor);
  console.log(`Rodada terminada: ${processados} pedido(s).`);
}

rodada().catch((erro) => {
  console.error(erro.message);
  process.exitCode = 1;
});

buscar_pedidos.py

O mesmo em Python 3.9+, só biblioteca padrão

Como rodar SOFTEUM_CHAVE=sp_live_... python3 buscar_pedidos.py

# Exemplo de busca dos pedidos pela API do Softeum Pedidos, em Python 3.9 ou mais, só com a biblioteca padrão.
# Para o ERP que não recebe o aviso (sem endereço HTTPS público): ele busca os pedidos prontos, lança e confirma.
#
# Rodar:   SOFTEUM_CHAVE=sp_live_... python3 buscar_pedidos.py
#          (agende a cada 1 a 5 minutos: cron, Agendador de Tarefas do Windows, job do ERP)
# Outro endereço (homologação): SOFTEUM_API=https://... python3 buscar_pedidos.py
# No ERP de testes (homologação): SOFTEUM_HOMOLOGACAO=1 SOFTEUM_CHAVE=sp_live_... python3 buscar_pedidos.py
#          Só com ela o pedido de teste do painel ("teste": true) é lançado. Sem ela (o ERP de produção), ele é
#          recusado com o motivo e sai da fila: o ERP de produção nunca lança um pedido de teste.

import json
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

API = os.environ.get("SOFTEUM_API", "https://api.softeum.com.br/v1")
CHAVE = os.environ.get("SOFTEUM_CHAVE")
HOMOLOGACAO = os.environ.get("SOFTEUM_HOMOLOGACAO") == "1"

if not CHAVE:
    sys.exit("Defina SOFTEUM_CHAVE com a chave de acesso do painel (Integração → Como o ERP recebe → O ERP busca os pedidos).")


class Recusa(Exception):
    """Lance com o motivo para recusar o pedido (cliente bloqueado, condição que não existe no ERP)."""


def chamar(metodo: str, caminho: str, corpo=None):
    """Chamada à API. Erros vêm em application/problem+json (title, detail, erros[])."""
    dados = json.dumps(corpo).encode() if corpo is not None else None
    for tentativa in range(1, 5):
        pedido = urllib.request.Request(f"{API}{caminho}", data=dados, method=metodo)
        pedido.add_header("Authorization", f"Bearer {CHAVE}")
        if dados is not None:
            pedido.add_header("Content-Type", "application/json")
        try:
            with urllib.request.urlopen(pedido, timeout=30) as resposta:
                return resposta.status, json.loads(resposta.read() or b"null")
        except urllib.error.HTTPError as erro:
            # Muitas chamadas: espera o que o Retry-After pede e tenta de novo.
            if erro.code == 429 and tentativa <= 3:
                time.sleep(int(erro.headers.get("Retry-After", "10")))
                continue
            try:
                return erro.code, json.loads(erro.read() or b"null")
            except ValueError:
                return erro.code, None
    raise RuntimeError("A API continuou respondendo 429.")


def lancar_no_erp(pedido: dict) -> str:
    """Aqui entra o lançamento de verdade no ERP. Devolve o número do pedido de venda criado.
    Lance de forma idempotente: se pedido["id"] já foi lançado, devolva o número existente (UNIQUE no id)."""
    print(f"Lançando o pedido {pedido['numero_comprador']} de {pedido['comprador']['razao_social']}: "
          f"{len(pedido['itens'])} item(ns), R$ {pedido['valor_total']}")
    return str(500000 + random.randint(0, 999))


def recusar(pedido: dict, motivo: str):
    """Recusa o pedido com o motivo: ele sai de "pronto" e o painel mostra o motivo ao time."""
    status, corpo = chamar("POST", f"/pedidos/{pedido['id']}/recusa", {"motivo": motivo})
    print(f"  recusado: {motivo}" if status == 200 else f"  recusa respondeu {status}: {(corpo or {}).get('detail')}")


def rodada():
    # Cada rodada começa do início da fila de prontos (sem cursor): o pedido sai de "pronto" quando é confirmado ou
    # recusado, então o que ficou para trás aparece de novo. O cursor serve só para ler as páginas da mesma rodada.
    cursor = None
    processados = 0
    while True:
        consulta = "/pedidos?situacao=pronto&limite=100" + (f"&cursor={urllib.parse.quote(cursor)}" if cursor else "")
        status, lista = chamar("GET", consulta)
        if status != 200:
            raise RuntimeError(f"A lista respondeu {status}: {(lista or {}).get('detail', '')}")
        for pedido in lista["dados"]:
            # O pedido de teste do painel (seção 8 do guia) só entra no ERP de testes: no de produção, sai da fila recusado.
            if pedido.get("teste") is True and not HOMOLOGACAO:
                print(f"Pedido de teste {pedido['numero_comprador']}: não é lançado no ERP de produção.")
                recusar(pedido, "Pedido de teste: não lançado no ERP de produção.")
                processados += 1
                continue
            try:
                numero = lancar_no_erp(pedido)
                status, corpo = chamar("POST", f"/pedidos/{pedido['id']}/confirmacao", {"numero_erp": numero})
                print(f"  confirmado como {numero}" if status == 200 else f"  confirmação respondeu {status}: {(corpo or {}).get('detail')}")
            except Recusa as recusa:
                recusar(pedido, str(recusa))
            except Exception as erro:  # falha do ERP: o pedido continua pronto e volta na próxima rodada
                print(f"  não deu para lançar agora: {erro}", file=sys.stderr)
            processados += 1
        cursor = lista["proximo_cursor"]
        if not cursor:
            break
    print(f"Rodada terminada: {processados} pedido(s).")


if __name__ == "__main__":
    rodada()

kit-para-o-ti.md

O kit para a TI do cliente

Como rodar Leia primeiro

openapi.json

O contrato da API em OpenAPI 3.1: abre no Swagger Editor, no Postman e no Insomnia, e geradores como o OpenAPI Generator criam as classes a partir dele.