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.