Webhooks em Zig: HMAC, Idempotência e Filas sem Framework Pesado

Webhooks parecem simples até o primeiro incidente. Um serviço externo envia um POST, seu servidor responde 200, e pronto. Na prática, o endpoint precisa lidar com corpo grande demais, assinatura inválida, relógio fora de janela, repetição do mesmo evento, tentativa de replay, timeout do provedor, queda do banco, ordem de entrega diferente da esperada e deploy no meio do processamento. É o tipo de fronteira em que Zig brilha quando você quer comportamento explícito em vez de mágica de framework.

A proposta deste guia é montar uma arquitetura pragmática para receber webhooks em Zig: validar HMAC, limitar memória, registrar o evento de forma idempotente, responder rápido e processar depois em uma fila. O foco não é copiar o SDK de Stripe, GitHub, Lemon Squeezy ou qualquer provedor específico. O foco é o desenho que se repete em produção quando um binário Zig precisa conversar com sistemas externos sem perder eventos.

Este artigo complementa servidor HTTP em Zig para produção, JWT e autenticação em Zig, filas e workers em background, configuração segura com segredos e variáveis de ambiente e OpenAPI em Zig. A mentalidade é a mesma: deixar a fronteira pequena, previsível e testável.

O problema real dos webhooks

Um webhook é uma integração assíncrona disfarçada de requisição HTTP. O provedor não quer esperar sua regra de negócio inteira. Ele quer saber se você recebeu o evento. Se sua aplicação demora, responde erro por causa de uma dependência secundária ou processa tudo dentro do handler, o provedor normalmente tenta de novo. Isso é correto, mas muda o contrato: o mesmo evento pode chegar mais de uma vez.

Portanto, o endpoint não deve ser escrito como uma rota comum de formulário. Ele precisa responder perguntas operacionais:

  • o corpo cabe no limite definido para esse provedor?
  • a assinatura HMAC bate com o segredo atual?
  • o timestamp está dentro da janela aceitável?
  • o event_id já foi recebido antes?
  • o payload bruto foi persistido antes de responder?
  • o processamento de negócio pode ser repetido sem duplicar efeito?
  • existe trilha de auditoria para investigar divergências?

A regra prática: handler de webhook recebe, valida, grava e enfileira. Worker processa.

Modelo de dados mínimo

Mesmo em projetos pequenos, vale separar o registro bruto do efeito de negócio. Um modelo simples funciona bem:

CampoUso
provideridentifica a origem: stripe, github, internal
event_idchave idempotente do provedor
event_typetipo lógico: invoice.paid, push, user.updated
received_athorário de chegada no seu sistema
signature_okresultado da validação para auditoria
payload_sha256hash do corpo bruto
payloadcorpo bruto ou referência a storage
statusqueued, processing, done, failed_retryable, failed_permanent
attemptsquantas vezes o worker tentou processar

A chave única deve incluir pelo menos (provider, event_id). Se o provedor não envia um ID confiável, você pode usar payload_sha256 mais timestamp e tipo, mas isso é um fallback pior. Para eventos que geram dinheiro, licença, acesso ou envio externo, idempotência por ID é indispensável.

Handler HTTP: pequeno e defensivo

O handler não precisa entender toda a regra de negócio. Ele precisa ser chato e previsível.

const std = @import("std");

const MaxWebhookBody = 256 * 1024;

const WebhookError = error{
    MissingSignature,
    InvalidSignature,
    BodyTooLarge,
    InvalidJson,
    MissingEventId,
    StorageUnavailable,
};

pub const WebhookRequest = struct {
    provider: []const u8,
    signature: []const u8,
    timestamp: []const u8,
    body: []const u8,
};

pub fn handleWebhook(allocator: std.mem.Allocator, req: WebhookRequest) !u16 {
    if (req.body.len > MaxWebhookBody) return WebhookError.BodyTooLarge;

    try verifySignature(req.signature, req.timestamp, req.body);

    const parsed = try parseEnvelope(allocator, req.body);
    defer parsed.deinit(allocator);

    try storeAndEnqueue(.{
        .provider = req.provider,
        .event_id = parsed.event_id,
        .event_type = parsed.event_type,
        .payload = req.body,
    });

    return 202;
}

O status 202 Accepted comunica melhor o contrato do que 200 OK: você recebeu e aceitou para processamento, mas ainda não concluiu o efeito final. Alguns provedores só exigem qualquer 2xx; outros documentam comportamento específico. Siga o provedor, mas mantenha a semântica interna.

Validação HMAC sem inventar protocolo

A maioria dos provedores assina o corpo bruto com um segredo compartilhado. O detalhe importante é “corpo bruto”. Se você parseia JSON, reserializa e assina o resultado, a assinatura muda por causa de espaços, ordem de campos e escapes. Grave e valide os bytes recebidos.

Um desenho genérico:

fn verifySignature(signature_header: []const u8, timestamp: []const u8, body: []const u8) !void {
    if (signature_header.len == 0) return WebhookError.MissingSignature;

    const secret = loadWebhookSecret();
    const signed_payload = try buildSignedPayload(timestamp, body);
    defer signed_payload.deinit();

    var mac: [32]u8 = undefined;
    std.crypto.auth.hmac.sha2.HmacSha256.create(
        &mac,
        signed_payload.items,
        secret,
    );

    const expected_hex = try std.fmt.allocPrint(
        std.heap.page_allocator,
        "{s}",
        .{std.fmt.fmtSliceHexLower(&mac)},
    );
    defer std.heap.page_allocator.free(expected_hex);

    if (!std.crypto.timing_safe.eql([32]u8, mac, try decodeHeader(signature_header))) {
        return WebhookError.InvalidSignature;
    }
}

O exemplo acima é deliberadamente esquemático porque cada provedor formata a string assinada de um jeito. Alguns assinam timestamp.body, outros assinam apenas o body, outros mandam múltiplas assinaturas para rotação de segredo. A parte que não muda é: use HMAC da biblioteca padrão, compare em tempo constante e nunca coloque o segredo no log. Para segredos em produção, combine com o padrão de configuração segura em Zig.

Janela de tempo e replay

HMAC prova que alguém conhece o segredo. Não prova que o evento é novo. Por isso, muitos provedores incluem timestamp no cabeçalho assinado. Rejeite eventos muito antigos, por exemplo acima de cinco minutos, se o provedor recomenda esse comportamento.

A janela reduz ataque de replay, mas não substitui idempotência. Um evento legítimo pode ser reenviado dentro da janela por timeout de rede. O endpoint deve aceitar a repetição e responder 2xx se o evento já foi gravado. O worker não deve executar o mesmo efeito duas vezes.

Idempotência no banco

A idempotência mais robusta não mora em memória. Mora em uma constraint do banco.

CREATE TABLE webhook_events (
  id INTEGER PRIMARY KEY,
  provider TEXT NOT NULL,
  event_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload_sha256 TEXT NOT NULL,
  payload TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'queued',
  attempts INTEGER NOT NULL DEFAULT 0,
  received_at TEXT NOT NULL,
  UNIQUE(provider, event_id)
);

No handler, tente inserir. Se a constraint falhar porque o evento já existe, responda sucesso. Não trate duplicata como erro de integração. Ela é parte normal do contrato.

const InsertResult = enum { inserted, duplicate };

fn storeAndEnqueue(event: IncomingEvent) !InsertResult {
    // Pseudocódigo: use o driver SQLite/Postgres real do projeto.
    // INSERT ... ON CONFLICT(provider, event_id) DO NOTHING
    // Se inseriu: publicar job ou deixar worker buscar status='queued'.
    // Se duplicou: não executar efeito de negócio de novo.
    return .inserted;
}

Para ferramentas locais e integrações pequenas, SQLite pode ser suficiente. Para múltiplas réplicas ou alto volume, Postgres tende a ser mais simples de operar com locks, retries e workers concorrentes. Veja também SQLite em Zig para ferramentas locais e integrações com bancos de dados em Zig.

Responda rápido, processe depois

O erro mais comum é chamar APIs externas, enviar email, recalcular permissões e atualizar múltiplas tabelas antes de responder ao provedor. Isso transforma qualquer lentidão interna em reentrega externa.

Prefira este fluxo:

  1. ler corpo com limite;
  2. validar assinatura;
  3. extrair event_id e event_type mínimos;
  4. inserir payload bruto com chave única;
  5. retornar 2xx;
  6. worker busca eventos queued e aplica regra de negócio.

O worker pode ter retry com backoff, dead-letter manual e métricas. O endpoint deve continuar simples. Essa separação também facilita testes: você testa validação HTTP de um lado e processamento de negócio do outro.

Erros e códigos de resposta

Nem todo erro merece o mesmo status.

SituaçãoResposta sugerida
assinatura ausente ou inválida401 ou 403
corpo acima do limite413
JSON inválido400
evento duplicado já registrado200 ou 202
banco indisponível antes de gravar500
falha no worker depois de gravarhandler já respondeu; worker marca retry

Se o banco caiu antes de persistir o evento, não responda sucesso. Deixe o provedor tentar novamente. Se o evento foi persistido e o worker falhou depois, não peça reentrega HTTP; use sua fila interna.

Testes de contrato

Webhooks quebram em detalhes pequenos: header mudou, timestamp veio em segundos em vez de milissegundos, provedor adicionou campo, assinatura passa a incluir prefixo. Guarde payloads reais sanitizados como fixtures e teste o handler contra eles.

Casos mínimos:

  • payload válido com assinatura válida;
  • assinatura errada;
  • timestamp fora da janela;
  • corpo maior que o limite;
  • evento duplicado;
  • JSON válido com campo extra;
  • JSON sem event_id;
  • falha de storage antes de responder.

Combine testes unitários de HMAC com teste de integração do fluxo completo. Se você usa OpenAPI para documentação interna, documente o endpoint como rota de fronteira, mas não dependa só de schema: assinatura, replay e idempotência são comportamento, não apenas formato.

Observabilidade do endpoint

Um webhook sem observabilidade vira caixa-preta. Registre pelo menos:

  • contagem por provider e event_type;
  • duplicatas por provedor;
  • assinaturas inválidas;
  • latência do handler até persistir;
  • tamanho do payload;
  • idade do evento quando recebido;
  • backlog e tempo de processamento do worker;
  • falhas permanentes por tipo.

Não logue payload completo por padrão. Ele pode conter dados pessoais, tokens, emails, endereços ou detalhes de pagamento. Logue event_id, hash do payload, tipo e status. Para conectar essa rota ao resto do sistema, use as ideias de OpenTelemetry em Zig e observabilidade em Zig.

Checklist de produção

Antes de expor o endpoint publicamente, confira:

  • segredo carregado por configuração, não hardcoded;
  • suporte a rotação de segredo se o provedor permitir múltiplas assinaturas;
  • limite de corpo menor que o limite global do servidor;
  • comparação de assinatura em tempo constante;
  • janela de timestamp documentada;
  • constraint única em (provider, event_id);
  • worker idempotente mesmo se rodar duas vezes;
  • payload bruto persistido antes do 2xx;
  • métricas e logs sem vazar segredo ou payload sensível;
  • fixtures de teste com payload real sanitizado;
  • runbook para reprocessar evento failed_retryable.

Quando Zig é uma boa escolha

Zig não torna webhook automaticamente mais seguro. O que ele oferece é controle. Você decide limite de memória, formato do erro, alocador, serialização, comparação criptográfica e caminho de persistência. Para integrações críticas, essa explicitude é vantagem: menos comportamento escondido, binário pequeno, deploy simples e testes que cobrem a fronteira real.

Se o projeto precisa só receber um formulário de baixa importância, qualquer stack resolve. Se o webhook aciona cobrança, provisionamento, licença, entrega, build, deploy ou sincronização de dados, trate a rota como infraestrutura. Em Zig, isso significa um handler pequeno, HMAC bem validado, idempotência no banco, fila clara e worker observável. O resto é detalhe de provedor.

Para comparar a ergonomia com outra linguagem muito usada em backends de integração, veja também tratamento explícito de erros em Go. O ponto em comum é o mesmo que torna webhooks confiáveis: falhas precisam ser parte do desenho, não exceção surpresa no caminho feliz.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.