genko.meDocs
Frameworks and languagesGo

Tutorial

Implement pagination, preview saved drafts, and verify webhook signatures with crypto/hmac in Go.

Use the Client from Get started to add three features needed on a real site.

Pagination

The delivery API has no page-number parameter. Send limit and offset, then calculate the page count from the returned TotalCount.

genko/page.go
package genko
import (	"context"	"net/url"	"strconv")
// Page contains one page of results and pagination metadata.type Page[T any] struct {	Contents   []T	TotalCount int	TotalPages int	Number     int}
func (p *Page[T]) HasPrevious() bool { return p.Number > 1 }func (p *Page[T]) HasNext() bool     { return p.Number < p.TotalPages }
// GetPage fetches a list using a one-based page number.func GetPage[T any](ctx context.Context, c *Client, endpoint string, number, perPage int) (*Page[T], error) {	if number < 1 {		number = 1	}
	list, err := GetList[T](ctx, c, endpoint, url.Values{		"limit":  {strconv.Itoa(perPage)},		"offset": {strconv.Itoa((number - 1) * perPage)},		"orders": {"-publishedAt"},	})	if err != nil {		return nil, err	}
	totalPages := (list.TotalCount + perPage - 1) / perPage	if totalPages < 1 {		totalPages = 1	}
	return &Page[T]{		Contents:   list.Contents,		TotalCount: list.TotalCount,		TotalPages: totalPages,		Number:     number,	}, nil}

The page number comes from the URL, so validate it before calculating offset. strconv.Atoi returns an error for an invalid number; in that case, use page 1.

go
number, err := strconv.Atoi(r.URL.Query().Get("page"))if err != nil || number < 1 {    number = 1}
page, err := genko.GetPage[genko.Post](r.Context(), client, "blog", number, 10)

limit must be an integer from 1 to 100. Values outside that range return 400. If offset exceeds the total count, Contents is an empty slice rather than an error.

Do not fetch every entry in a loop inside a handler. External calls per request would grow with the entry count and reach the 300 requests per minute limit. Fetch all entries at startup or on a schedule for jobs such as sitemap or feed generation, and keep the result.

When you need all entries (scheduled job)
func GetAll[T any](ctx context.Context, c *Client, endpoint string, query url.Values) ([]T, error) {	const perPage = 100	var entries []T
	for offset := 0; ; offset += perPage {		page := url.Values{}		for key, values := range query {			page[key] = values		}		page.Set("limit", strconv.Itoa(perPage))		page.Set("offset", strconv.Itoa(offset))
		list, err := GetList[T](ctx, c, endpoint, page)		if err != nil {			return nil, err		}
		entries = append(entries, list.Contents...)		if offset+perPage >= list.TotalCount {			return entries, nil		}	}}

If an entry is published or deleted while you page through results with offset, positions shift and you may get duplicates or miss entries. This is unsuitable for a point-in-time backup.

Preview drafts

The dashboard's Preview action opens a configured URL template with its draftKey substituted. Add that key to the detail request query to fetch the saved draft.

handler.go
func postHandler(client *genko.Client) http.HandlerFunc {	return func(w http.ResponseWriter, r *http.Request) {		id := r.PathValue("id")		draftKey := r.URL.Query().Get("draftKey")
		// An empty draftKey returns 400, so pass it only when present.		query := url.Values{}		if draftKey != "" {			query.Set("draftKey", draftKey)		}
		post, err := genko.GetEntry[genko.Post](r.Context(), client, "blog", id, query)
		var apiErr *genko.APIError		if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {			http.NotFound(w, r)			return		}		if err != nil {			log.Printf("genko: %v", err)			http.Error(w, "Could not fetch entry", http.StatusInternalServerError)			return		}
		if draftKey != "" {			// Keep previews out of search indexes and referrer headers.			w.Header().Set("X-Robots-Tag", "noindex, nofollow")			w.Header().Set("Referrer-Policy", "no-referrer")			w.Header().Set("Cache-Control", "private, no-store")		}
		renderPost(w, post)	}}

r.PathValue is part of net/http routing in Go 1.22 and later. With an earlier Go version or a router such as chi or gorilla/mux, use that router's parameter lookup.

Set the preview URL template in the dashboard's API settings (/w/your-workspace/apis/blog/settings).

text
https://your-site.example/blog/{id}?draftKey={key}

Keep these constraints in mind:

  • Preview works for a single entry or an object API only. Adding draftKey to a list request does not produce a draft list.
  • The key is 1–64 characters long. An empty string returns 400; a well-formed key without a matching draft returns 404.
  • Do not cache preview results. Sharing a cache with the published version could serve a draft on a public page.
  • Set noindex, nofollow and no-referrer on the page. Hide share buttons and canonical URLs that point to a public page during preview.
  • Unsaved edits are not included. Save in the dashboard before opening the preview.

Rebuild or revalidate with webhooks

genko.me sends a small JSON POST when content changes. It does not include the entry body; fetch the entry from the delivery API after receiving the webhook if needed.

A webhook with a secret includes x-genko-timestamp (Unix seconds) and an HMAC-SHA256 hexadecimal signature, prefixed with sha256=, in x-genko-signature. Sign timestamp.rawBody and reject timestamps outside a 300-second window. In Go, generate the same value with crypto/hmac and compare it with hmac.Equal.

Verify the signature against the raw request body before parsing JSON. Use the bytes from io.ReadAll(r.Body) directly, then call json.Unmarshal. Decoding directly from the stream with json.NewDecoder(r.Body).Decode() consumes the body you need to verify.

genko/webhook.go
package genko
import (	"crypto/hmac"	"crypto/sha256"	"encoding/hex"	"strconv"	"time")
// Payload is the JSON sent by a genko.me webhook.type Payload struct {	Event     string `json:"event"`	Workspace string `json:"workspace"`	API       string `json:"api"`	// This is null for a test delivery.	Entry *struct {		ID     string `json:"id"`		Status string `json:"status"`	} `json:"entry"`	TriggeredAt string `json:"triggeredAt"`}
// ValidSignature checks the x-genko-signature header.// The signature is `sha256=` followed by the hexadecimal HMAC-SHA256 of `timestamp.rawBody`.func ValidSignature(secret, rawBody []byte, timestamp, signatureHeader string) bool {	seconds, err := strconv.ParseInt(timestamp, 10, 64)	if err != nil || timestamp != strconv.FormatInt(seconds, 10) || time.Now().Unix()-seconds > 300 || seconds-time.Now().Unix() > 300 { return false }	mac := hmac.New(sha256.New, secret)	mac.Write([]byte(timestamp + "."))	mac.Write(rawBody)	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	// hmac.Equal prevents timing attacks. Do not compare with ==.	return hmac.Equal([]byte(expected), []byte(signatureHeader))}
webhook_handler.go
func webhookHandler(secret []byte, refresh chan<- string) http.HandlerFunc {	return func(w http.ResponseWriter, r *http.Request) {		if r.Method != http.MethodPost {			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)			return		}
		// Read with a size limit; do not accept unbounded input.		rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))		if err != nil {			http.Error(w, "Cannot read body", http.StatusBadRequest)			return		}
		if !genko.ValidSignature(secret, rawBody, r.Header.Get("X-Genko-Timestamp"), r.Header.Get("X-Genko-Signature")) {			http.Error(w, "Invalid signature", http.StatusUnauthorized)			return		}
		var payload genko.Payload		if err := json.Unmarshal(rawBody, &payload); err != nil {			http.Error(w, "Invalid JSON", http.StatusBadRequest)			return		}
		// Return promptly and hand rebuilding to another goroutine through a channel.		select {		case refresh <- payload.API:		default: // If the channel is full, skip this event; a later event will trigger the work.		}
		w.Header().Set("Content-Type", "application/json")		_ = json.NewEncoder(w).Encode(map[string]bool{"received": true})	}}

r.Header.Get is case-insensitive. X-Genko-Signature also retrieves x-genko-signature.

Receiver considerations

ItemDetails
Timeout10 seconds. There are no automatic retries
DuplicatesManual resend from delivery logs can deliver the same event more than once. Make handling safe to repeat
EventsSaving an entry by manually clicking Publish sends entry.created or entry.updated, not entry.published
Unpublishentry.updated sets Entry.Status to draft. Invalidate the old public page too
Test deliveryEvent is webhook.test and Entry is nil. Handle the nil entry

A synchronous rebuild inside the handler can exceed the 10-second timeout. As above, hand it to a channel or job and return 200 promptly.

See Webhooks for registration, delivery logs, and troubleshooting.

Last updated: October 14, 2026

On this page