# Verifikasi tanda tangan webhook di Go

> Verifikasi tanda tangan webhook Ikiform di Go dengan net/http: baca body sekali, periksa dengan crypto/hmac, uji, atau gunakan library Standard Webhooks.

Terbit 2026-10-25 · https://ikiform.com/id/guides/webhook-signature-verification/go

Baca seluruh body dengan `io.ReadAll` di balik `http.MaxBytesReader`, lalu hitung HMAC-SHA256 dari `id.timestamp.body` dengan secret `whsec_` hasil decode base64 dan bandingkan dengan setiap tanda tangan `v1` memakai `hmac.Equal`. Pastikan timestamp dalam 5 menit, dan baru setelah itu `json.Unmarshal` byte yang sama. Library Go Standard Webhooks melakukan hal yang sama dalam satu panggilan `Verify`.

Standard library Go punya semua yang dibutuhkan pemeriksaan ini, jadi halaman ini tidak memakai framework atau dependency sampai library opsional di bagian akhir. Handler-nya `net/http` biasa dengan pola method-dan-path yang ditambahkan di Go 1.22, dan function yang sama bisa dipakai di chi, Gin, atau Echo tanpa perubahan.

## Apa yang berbeda di Go

Tidak ada bagian `net/http` yang mem-parsing body untuk Anda, jadi jebakan body mentah yang biasa tidak ada. Jebakan khas Go adalah stream itu sendiri: `r.Body` hanya bisa dibaca sekali. `json.NewDecoder(r.Body).Decode(&event)` yang idiomatis menghabiskannya, dan tidak ada yang tersisa untuk diverifikasi. Jadi baca byte-nya ke memori lebih dulu, dengan batas agar post besar tidak menghabiskan memori, dan gunakan satu slice itu untuk kedua tugas.

Diagram: Urutan operasi di handler webhook Go

1. MaxBytesReader membungkus body agar pembacaan berhenti setelah 300 KB, di atas batas payload Ikiform 256 KB.
2. io.ReadAll membaca seluruh body ke satu byte slice.
3. VerifySignature memeriksa header, timestamp, dan setiap tanda tangan v1 terhadap byte itu.
4. Jika berhasil, json.Unmarshal mem-parsing slice yang sama ke dalam struct.
5. Handler menjawab 204. Pada error verifikasi apa pun, handler menjawab 400 beserta alasannya.

json.Decoder pada r.Body akan melewati langkah kedua dan tidak menyisakan apa pun untuk diverifikasi.

## Function verifikasi

`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` menormalkan nama, jadi `webhook-id` menemukan `Webhook-Id` bagaimanapun request menuliskannya.
- `hmac.Equal` bersifat constant-time dan mengembalikan `false` untuk panjang yang berbeda, jadi tanda tangan yang cacat cukup dianggap tidak cocok.
- `strings.Cut` hanya memisahkan di koma pertama, sesuai yang diminta spesifikasi.
- `base64.StdEncoding` adalah alfabet standar dengan padding. `RawStdEncoding` atau `URLEncoding` akan menolak atau salah men-decode secret maupun tanda tangan.
- Key di-decode sekali saat startup oleh `DecodeSecret`, bukan di setiap request.

## Handler-nya

`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` melindungi server dari klien yang lambat; `http.ListenAndServe` polos sama sekali tidak punya timeout. Struct hanya menyimpan field yang Anda pakai. `json.Unmarshal` mengabaikan sisanya, jadi field payload baru tidak pernah merusaknya. Di balik reverse proxy seperti nginx, pertahankan batas body-nya, `client_max_body_size`, di atas 256 KB juga, atau proxy menolak kiriman besar sebelum Go melihatnya. Body `400` membawa teks error, seperti `no_matching_signature`, dan log pengiriman menampilkan 4 KB pertama dari setiap respons, jadi kegagalan menjelaskan dirinya sendiri di sana.

> Di Gin, baca body dengan `c.GetRawData()`; di Echo, `io.ReadAll(c.Request().Body)`; di chi, handler di atas langsung berfungsi. Jangan panggil `c.ShouldBindJSON` atau `c.Bind` sebelum verifikasi, karena alasan sekali-baca yang sama.

## Table test

`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)
			}
		})
	}
}
```

Jalankan `go test ./...`. Kasus-kasusnya berasal dari [test vector](https://ikiform.com/id/guides/webhook-signature-verification.md#test-vector). `json.Indent` menghasilkan body serialisasi ulang yang mungkin muncul di codebase Go sungguhan, dan `now` yang tetap menjaga timestamp vector di dalam jendela.

## Pengulangan dan pekerjaan lambat

Dua hal terjadi setelah pengiriman terverifikasi. Pertama, lewati jika sudah pernah Anda lihat: Ikiform mencoba ulang dengan `webhook-id` yang sama saat tidak mendapat `2xx` dalam 15 detik, meski handler Anda sudah selesai. Dengan PostgreSQL dan kolom unik, `RowsAffected` memberi tahu apakah ini yang pertama:

`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
}
```

Kedua, jawab sebelum pekerjaan lambat. Goroutine adalah alat yang jelas, dengan satu jebakan khas Go: `r.Context()` dibatalkan begitu handler kembali, jadi panggilan database atau request HTTP apa pun yang dibuat dengannya di latar belakang gagal dengan `context canceled`. Beri goroutine context-nya sendiri:

`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)
```

Goroutine mati bersama prosesnya, jadi deploy di tengah pekerjaan akan menghilangkannya. Untuk apa pun yang tidak boleh hilang, tulis event ke antrean atau tabel di dalam handler, jawab `204`, dan proses dari sana.

## Dengan library Standard Webhooks

Library Go dari spesifikasi ini ada di modul `libraries` di repositorinya. Tambahkan dengan `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` menghapus `whsec_` dan men-decode secret. `Verify` menerima body dan `r.Header` secara langsung dan mengembalikan error yang membungkus `ErrNoMatchingSignature`, `ErrMessageTooOld`, dan lainnya, jadi Anda bisa membedakannya dengan `errors.Is`. Ada juga `VerifyIgnoringTimestamp`; jangan memakainya di produksi, karena membuka kembali celah replay.

## Pastikan berfungsi

1. Jalankan `go test ./...`. Kelima kasus lolos.
2. Jalankan server di balik URL `https:` publik dan tambahkan di **Webhooks** di dashboard.
3. Ekspor `IKIFORM_WEBHOOK_SECRET` dengan secret `whsec_` milik endpoint. Server menolak berjalan tanpa secret yang valid.
4. Pilih **Send test**. Log pengiriman menampilkan `204`.
5. Kirim request dengan curl dan tanda tangan karangan. Request itu mendapat `400 no_matching_signature`.

## Hal yang sering menjebak

| Masalah | Penyebab dan solusi |
| --- | --- |
| Body kosong saat Anda memverifikasi | Ada yang membaca `r.Body` lebih dulu, sering kali decoder atau middleware logging. Baca sekali dan teruskan slice-nya |
| `illegal base64 data` saat startup | Secret di-decode dengan `RawStdEncoding`, atau disalin tanpa `=` di akhirnya. Gunakan `StdEncoding` dan nilai lengkapnya |
| `404` di path webhook | Pola seperti `POST /webhooks/ikiform` butuh Go 1.22 atau lebih baru dan baris `go 1.22` atau lebih baru di `go.mod`; pengaturan lama memperlakukan pola itu sebagai path literal |
| `413` di log pengiriman | Batas `MaxBytesReader` di bawah 256 KB. Pertahankan di atas batas payload Ikiform |
| Test vector gagal dengan `timestamp_too_old` | Test memakai `time.Now()`. Berikan `now` yang tetap dari test, seperti di atas |

## Pertanyaan

### Bagaimana membandingkan tanda tangan HMAC dengan aman di Go?

Gunakan hmac.Equal dari crypto/hmac. Fungsi ini membandingkan secara constant-time dan mengembalikan false saat panjangnya berbeda. Jangan pakai bytes.Equal atau ==, yang bisa langsung kembali begitu satu byte berbeda.

### Mengapa r.Body kosong setelah saya men-decode JSON?

Body request adalah stream dan hanya bisa dibaca sekali. Baca ke byte slice dengan io.ReadAll, verifikasi slice itu, lalu json.Unmarshal slice yang sama.

### Apakah ada library Standard Webhooks untuk Go?

Ada, di repositori standard-webhooks di bawah libraries/go. NewWebhook(secret) menerima secret whsec_, dan Verify(body, r.Header) mengembalikan error saat pemeriksaan gagal.

### Haruskah saya memakai crypto/subtle alih-alih hmac.Equal?

Tidak perlu. hmac.Equal memanggil subtle.ConstantTimeCompare untuk Anda dan merupakan cara terdokumentasi untuk membandingkan MAC. Keduanya constant-time; == dan bytes.Equal tidak.

## Panduan lainnya

- [Verifikasi tanda tangan webhook](https://ikiform.com/id/guides/webhook-signature-verification.md): Pastikan webhook formulir benar-benar berasal dari Ikiform: skema HMAC-SHA256 Standard Webhooks, test vector, body mentah, timestamp, dan rotasi secret.
- [Node.js](https://ikiform.com/id/guides/webhook-signature-verification/nodejs.md): Verifikasi tanda tangan webhook Ikiform di Node.js: body mentah di Express dan Fastify, SDK, paket standardwebhooks, dan versi node:crypto yang sudah diuji.
- [Python](https://ikiform.com/id/guides/webhook-signature-verification/python.md): Verifikasi tanda tangan webhook Ikiform di Flask, FastAPI, dan Django, dengan paket standardwebhooks atau standard library, ditambah test pytest.
- [PHP](https://ikiform.com/id/guides/webhook-signature-verification/php.md): Verifikasi tanda tangan webhook Ikiform di PHP biasa dengan hash_hmac dan hash_equals, dan di Laravel dengan library Standard Webhooks dan pengecualian CSRF.
- [Ruby](https://ikiform.com/id/guides/webhook-signature-verification/ruby.md): Verifikasi tanda tangan webhook Ikiform di Sinatra dengan OpenSSL dan test Minitest, dan di Rails dengan gem standardwebhooks dan request.raw_post.
