Pular para o conteúdo principal

Receptor de webhook

Um receptor que cumpre as regras do Webhook de venda:

  1. Confere a assinatura sobre o corpo cru e responde 401 se não conferir.
  2. Grava o evento numa tabela com o id como chave única. Um id repetido responde 2xx sem efeito novo.
  3. Responde 200 na hora; o processamento roda depois, fora da requisição.
  4. No processamento, descarta o evento cujo sequence for menor que o último aplicado para a mesma venda.

A tabela de entrada pode ser assim:

create table etech_evento (
id uuid primary key, -- o id do evento: deduplicação
sale_id bigint, -- data.saleId; nulo no integration.ping
sequence bigint not null,
tipo text not null,
corpo jsonb not null,
recebido_em timestamptz not null default now(),
processado boolean not null default false
);

create table etech_venda_aplicada (
sale_id bigint primary key,
ultimo_sequence bigint not null
);
app.MapPost("/webhooks/etech", async (HttpRequest request, EventoRepositorio eventos, IConfiguration config) =>
{
using var leitor = new StreamReader(request.Body, Encoding.UTF8);
var corpoCru = await leitor.ReadToEndAsync();

var valida = AssinaturaWebhook.Valida(
config["Etech:WebhookSecret"]!,
request.Headers["X-Etech-Timestamp"],
corpoCru,
request.Headers["X-Etech-Signature"],
DateTimeOffset.UtcNow);
if (!valida)
return Results.Unauthorized();

using var json = JsonDocument.Parse(corpoCru);
var raiz = json.RootElement;
long? saleId = raiz.GetProperty("data").TryGetProperty("saleId", out var s) ? s.GetInt64() : null;

// INSERT ... ON CONFLICT (id) DO NOTHING: o repetido não muda nada e também recebe 200.
await eventos.GravarSeNovoAsync(
raiz.GetProperty("id").GetGuid(),
saleId,
raiz.GetProperty("sequence").GetInt64(),
raiz.GetProperty("type").GetString()!,
corpoCru);

return Results.Ok();
});

No processamento em segundo plano:

foreach (var evento in await eventos.PendentesAsync())
{
if (evento.SaleId is { } saleId)
{
var ultimo = await eventos.UltimoSequenceAplicadoAsync(saleId);
if (ultimo is not null && evento.Sequence < ultimo)
{
await eventos.MarcarProcessadoAsync(evento.Id); // evento mais velho que o já aplicado
continue;
}

await AplicarAsync(evento); // emitir NFC-e, debitar saldo...
await eventos.GuardarSequenceAsync(saleId, evento.Sequence);
}

await eventos.MarcarProcessadoAsync(evento.Id);
}
Reconciliação

Rode o feed de eventos de tempos em tempos (por exemplo a cada 15 minutos) e grave pela mesma rotina. Como a gravação deduplica pelo id, o que já chegou pelo webhook é ignorado e o que se perdeu entra.