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.
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.
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.
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.
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).
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
draftKeyto 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 returns404. - Do not cache preview results. Sharing a cache with the published version could serve a draft on a public page.
- Set
noindex, nofollowandno-referreron 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.
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))}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
| Item | Details |
|---|---|
| Timeout | 10 seconds. There are no automatic retries |
| Duplicates | Manual resend from delivery logs can deliver the same event more than once. Make handling safe to repeat |
| Events | Saving an entry by manually clicking Publish sends entry.created or entry.updated, not entry.published |
| Unpublish | entry.updated sets Entry.Status to draft. Invalidate the old public page too |
| Test delivery | Event 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.
Related
- Query reference: how to use
filters,q,depth, andfields - Response values: JSON for images, references, and content blocks
- Errors and limits: causes by status and rate limits
Last updated: October 14, 2026