# Verificar assinaturas de webhook em Go

> Verifique assinaturas de webhook do Ikiform em Go com net/http: leia o corpo uma vez, confira com crypto/hmac, teste, ou use a biblioteca do Standard Webhooks.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/webhook-signature-verification/go

Leia o corpo inteiro com `io.ReadAll` atrás de `http.MaxBytesReader`, depois calcule o HMAC-SHA256 de `id.timestamp.body` com o segredo `whsec_` decodificado de base64 e compare com cada assinatura `v1` usando `hmac.Equal`. Confira se o timestamp está dentro de 5 minutos e só então faça `json.Unmarshal` dos mesmos bytes. A biblioteca Go do Standard Webhooks faz o mesmo numa única chamada `Verify`.

A biblioteca padrão do Go tem tudo de que a verificação precisa, então esta página não usa framework nem dependência até a biblioteca opcional no final. O handler é `net/http` puro com os padrões de método e caminho adicionados no Go 1.22, e a mesma função encaixa no chi, Gin ou Echo sem mudanças.

## O que muda no Go

Nada no `net/http` faz o parse do corpo por você, o que elimina a armadilha de sempre do corpo bruto. A armadilha específica do Go é o próprio stream: `r.Body` só pode ser lido uma vez. O idiomático `json.NewDecoder(r.Body).Decode(&event)` o consome, e não sobra nada para verificar. Então leia os bytes para a memória primeiro, com um limite para que um envio grande não a esgote, e use essa única slice para as duas tarefas.

Diagrama: A ordem das operações num handler de webhook em Go

1. O MaxBytesReader envolve o corpo para que a leitura pare depois de 300 KB, acima do teto de 256 KB de payload do Ikiform.
2. O io.ReadAll lê o corpo inteiro numa única slice de bytes.
3. O VerifySignature confere os headers, o timestamp e cada assinatura v1 contra esses bytes.
4. Se der certo, o json.Unmarshal faz o parse da mesma slice numa struct.
5. O handler responde 204. Em qualquer erro de verificação, responde 400 com o motivo.

Um json.Decoder em r.Body pularia o segundo passo e não deixaria nada para verificar.

## A função de verificação

`signature.go`:

```text
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

var (
	ErrMissingHeaders   = errors.New("missing_headers")
	ErrInvalidTimestamp = errors.New("invalid_timestamp")
	ErrTooOld           = errors.New("timestamp_too_old")
	ErrTooNew           = errors.New("timestamp_too_new")
	ErrNoMatch          = errors.New("no_matching_signature")
)

func VerifySignature(body []byte, header http.Header, key []byte, now time.Time) error {
	id := header.Get("webhook-id")
	timestamp := header.Get("webhook-timestamp")
	signatures := header.Get("webhook-signature")
	if id == "" || timestamp == "" || signatures == "" {
		return ErrMissingHeaders
	}

	seconds, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return ErrInvalidTimestamp
	}
	age := now.Sub(time.Unix(seconds, 0))
	if age > tolerance {
		return ErrTooOld
	}
	if age < -tolerance {
		return ErrTooNew
	}

	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "."))
	mac.Write(body)
	expected := mac.Sum(nil)

	for _, entry := range strings.Split(signatures, " ") {
		version, value, found := strings.Cut(entry, ",")
		if !found || version != "v1" {
			continue
		}
		received, err := base64.StdEncoding.DecodeString(value)
		if err == nil && hmac.Equal(received, expected) {
			return nil
		}
	}
	return ErrNoMatch
}

func DecodeSecret(secret string) ([]byte, error) {
	return base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
}
```

- `header.Get` canonicaliza o nome, então `webhook-id` encontra `Webhook-Id` seja qual for a grafia usada na requisição.
- `hmac.Equal` é de tempo constante e devolve `false` para tamanhos diferentes, então uma assinatura malformada simplesmente não bate.
- `strings.Cut` divide só na primeira vírgula, que é o que a especificação pede.
- `base64.StdEncoding` é o alfabeto padrão com padding. `RawStdEncoding` ou `URLEncoding` recusariam ou decodificariam errado tanto o segredo quanto as assinaturas.
- A chave é decodificada uma vez na inicialização por `DecodeSecret`, não a cada requisição.

## O handler

`main.go`:

```text
package main

import (
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"os"
	"time"
)

type Event struct {
	ID   string `json:"id"`
	Type string `json:"type"`
	Data struct {
		SubmissionID string         `json:"submission_id"`
		Fields       map[string]any `json:"fields"`
	} `json:"data"`
}

func main() {
	key, err := DecodeSecret(os.Getenv("IKIFORM_WEBHOOK_SECRET"))
	if err != nil || len(key) == 0 {
		log.Fatal("IKIFORM_WEBHOOK_SECRET must be the whsec_ secret from the dashboard")
	}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /webhooks/ikiform", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 300<<10))
		if err != nil {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}
		if err := VerifySignature(body, r.Header, key, time.Now()); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		var event Event
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, "invalid_payload", http.StatusBadRequest)
			return
		}
		if event.Type == "submission.created" {
			log.Printf("submission %s %v", event.Data.SubmissionID, event.Data.Fields)
		}
		w.WriteHeader(http.StatusNoContent)
	})

	server := &http.Server{
		Addr:              ":3000",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}
```

`ReadHeaderTimeout` protege o servidor de clientes lentos; o `http.ListenAndServe` puro não tem timeout nenhum. A struct guarda só os campos que você usa. `json.Unmarshal` ignora o resto, então campos novos no payload nunca a quebram. Atrás de um proxy reverso como o nginx, mantenha também o limite de corpo dele, `client_max_body_size`, acima de 256 KB, ou o proxy recusa envios grandes antes de o Go vê-los. O corpo do `400` traz o texto do erro, como `no_matching_signature`, e o log de entregas mostra os primeiros 4 KB de cada resposta, então uma falha se explica ali mesmo.

> No Gin, leia o corpo com `c.GetRawData()`; no Echo, `io.ReadAll(c.Request().Body)`; no chi, o handler acima funciona como está. Não chame `c.ShouldBindJSON` nem `c.Bind` antes de verificar, pelo mesmo motivo da leitura única.

## Um teste de tabela

`signature_test.go`:

```text
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"net/http"
	"testing"
	"time"
)

const (
	secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
	rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
	body   = `{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}`
)

func vectorHeader(signature string) http.Header {
	header := http.Header{}
	header.Set("webhook-id", "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1")
	header.Set("webhook-timestamp", "1792300000")
	header.Set("webhook-signature", signature)
	return header
}

var now = time.Unix(1792300000, 0).Add(30 * time.Second)

func TestVerifySignature(t *testing.T) {
	key, _ := DecodeSecret(secret)
	rolledKey, _ := DecodeSecret(rolled)
	single := vectorHeader("v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=")
	both := vectorHeader("v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=")

	var pretty bytes.Buffer
	if err := json.Indent(&pretty, []byte(body), "", "  "); err != nil {
		t.Fatal(err)
	}

	cases := []struct {
		name   string
		body   []byte
		header http.Header
		key    []byte
		now    time.Time
		want   error
	}{
		{"vector", []byte(body), single, key, now, nil},
		{"old secret during a roll", []byte(body), both, key, now, nil},
		{"new secret during a roll", []byte(body), both, rolledKey, now, nil},
		{"re-serialized body", pretty.Bytes(), single, key, now, ErrNoMatch},
		{"stale timestamp", []byte(body), single, key, now.Add(10 * time.Minute), ErrTooOld},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := VerifySignature(c.body, c.header, c.key, c.now); !errors.Is(got, c.want) {
				t.Fatalf("got %v, want %v", got, c.want)
			}
		})
	}
}
```

Rode `go test ./...`. Os casos vêm do [vetor de teste](https://ikiform.com/pt-br/guides/webhook-signature-verification.md#test-vector). `json.Indent` gera o corpo reserializado que uma base de código Go real poderia produzir, e o `now` fixo mantém o timestamp do vetor dentro da janela.

## Repetições e trabalho lento

Duas coisas acontecem depois que uma entrega é verificada. Primeiro, ignore-a se já a viu: o Ikiform tenta de novo com o mesmo `webhook-id` quando não recebe um `2xx` em 15 segundos, mesmo que o seu handler tenha terminado. Com PostgreSQL e uma coluna única, `RowsAffected` diz se é a primeira vez:

`deliveries.go`:

```text
func firstDelivery(ctx context.Context, db *sql.DB, deliveryID string) (bool, error) {
	result, err := db.ExecContext(ctx,
		"INSERT INTO webhook_deliveries (delivery_id) VALUES ($1) ON CONFLICT (delivery_id) DO NOTHING",
		deliveryID,
	)
	if err != nil {
		return false, err
	}
	rows, err := result.RowsAffected()
	return rows == 1, err
}
```

Segundo, responda antes do trabalho lento. Uma goroutine é a ferramenta óbvia, com uma armadilha específica do Go: `r.Context()` é cancelado assim que o handler retorna, então qualquer chamada ao banco ou requisição HTTP feita com ele em segundo plano falha com `context canceled`. Dê à goroutine um contexto próprio:

`main.go`:

```text
go func(event Event) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	if err := forward(ctx, event); err != nil {
		log.Printf("forward %s: %v", event.ID, err)
	}
}(event)
w.WriteHeader(http.StatusNoContent)
```

Uma goroutine morre junto com o processo, então um deploy no meio do trabalho o perde. Para qualquer coisa que você não pode perder, grave o evento numa fila ou numa tabela dentro do handler, responda `204` e processe a partir dali.

## Com a biblioteca do Standard Webhooks

A biblioteca Go da especificação fica no módulo `libraries` do repositório dela. Adicione com `go get github.com/standard-webhooks/standard-webhooks/libraries`:

`main.go`:

```text
package main

import (
	"io"
	"log"
	"net/http"
	"os"

	standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

func main() {
	webhook, err := standardwebhooks.NewWebhook(os.Getenv("IKIFORM_WEBHOOK_SECRET"))
	if err != nil {
		log.Fatal(err)
	}

	http.HandleFunc("POST /webhooks/ikiform", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 300<<10))
		if err != nil {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}
		if err := webhook.Verify(body, r.Header); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})

	log.Fatal(http.ListenAndServe(":3000", nil))
}
```

`NewWebhook` tira o `whsec_` e decodifica o segredo. `Verify` recebe o corpo e `r.Header` diretamente e devolve um erro que envolve `ErrNoMatchingSignature`, `ErrMessageTooOld` e os outros, então você consegue diferenciá-los com `errors.Is`. Existe também `VerifyIgnoringTimestamp`; não use em produção, porque ele reabre a porta para replays.

## Confira se funciona

1. Rode `go test ./...`. Os cinco casos passam.
2. Suba o servidor atrás de uma URL `https:` pública e adicione-a em **Webhooks** no painel.
3. Exporte `IKIFORM_WEBHOOK_SECRET` com o segredo `whsec_` do endpoint. O servidor se recusa a iniciar sem um válido.
4. Escolha **Send test**. O log de entregas mostra `204`.
5. Envie uma requisição com curl e uma assinatura inventada. Ela recebe `400 no_matching_signature`.

## Pegadinhas

| Problema | Causa e correção |
| --- | --- |
| O corpo está vazio na hora de verificar | Algo leu `r.Body` antes, muitas vezes um decoder ou um middleware de log. Leia uma vez e repasse a slice |
| `illegal base64 data` na inicialização | O segredo foi decodificado com `RawStdEncoding`, ou copiado sem o `=` final. Use `StdEncoding` e o valor completo |
| `404` no caminho do webhook | Padrões como `POST /webhooks/ikiform` precisam do Go 1.22 ou posterior e de uma linha `go 1.22` ou posterior no `go.mod`; configurações mais antigas tratam o padrão como um caminho literal |
| `413` no log de entregas | O limite do `MaxBytesReader` está abaixo de 256 KB. Mantenha-o acima do teto de payload do Ikiform |
| O vetor de teste falha com `timestamp_too_old` | O teste usou `time.Now()`. Passe o `now` fixo do teste, como acima |

## Perguntas

### Como comparo assinaturas HMAC com segurança em Go?

Use hmac.Equal de crypto/hmac. Ele compara em tempo constante e devolve false quando os tamanhos são diferentes. Não use bytes.Equal nem ==, que podem retornar assim que um byte for diferente.

### Por que r.Body fica vazio depois que eu decodifico o JSON?

O corpo da requisição é um stream e só pode ser lido uma vez. Leia-o numa slice de bytes com io.ReadAll, verifique a slice e depois faça json.Unmarshal da mesma slice.

### Existe uma biblioteca do Standard Webhooks para Go?

Sim, no repositório standard-webhooks, em libraries/go. NewWebhook(secret) recebe o segredo whsec_, e Verify(body, r.Header) devolve um erro quando uma verificação falha.

### Devo usar crypto/subtle em vez de hmac.Equal?

Não precisa. O hmac.Equal chama subtle.ConstantTimeCompare por você e é a forma documentada de comparar MACs. Os dois são de tempo constante; == e bytes.Equal não são.

## Mais guias

- [Verificar assinaturas de webhook](https://ikiform.com/pt-br/guides/webhook-signature-verification.md): Confira se um webhook de formulário veio mesmo do Ikiform: o esquema HMAC-SHA256 do Standard Webhooks, um vetor de teste, corpos brutos, timestamps e rotação de segredo.
- [Node.js](https://ikiform.com/pt-br/guides/webhook-signature-verification/nodejs.md): Verifique assinaturas de webhook do Ikiform em Node.js: corpos brutos no Express e no Fastify, o SDK, o pacote standardwebhooks e uma versão testada com node:crypto.
- [Python](https://ikiform.com/pt-br/guides/webhook-signature-verification/python.md): Verifique assinaturas de webhook do Ikiform no Flask, FastAPI e Django, com o pacote standardwebhooks ou a biblioteca padrão, mais um teste com pytest.
- [PHP](https://ikiform.com/pt-br/guides/webhook-signature-verification/php.md): Verifique assinaturas de webhook do Ikiform em PHP puro com hash_hmac e hash_equals, e no Laravel com a biblioteca do Standard Webhooks e uma exceção de CSRF.
- [Ruby](https://ikiform.com/pt-br/guides/webhook-signature-verification/ruby.md): Verifique assinaturas de webhook do Ikiform no Sinatra com OpenSSL e um teste Minitest, e no Rails com a gem standardwebhooks e request.raw_post.
