Tutorial
Implement pagination, preview saved drafts, and verify webhook signatures with OpenSSL HMAC in Ruby.
Use Genko.get 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.
# frozen_string_literal: true
module Genko PER_PAGE = 10
Page = Data.define(:contents, :total_count, :total_pages, :number) do def previous = number > 1 ? number - 1 : nil def next = number < total_pages ? number + 1 : nil end
def self.page(number, per_page: PER_PAGE) list = get("/blog", limit: per_page, offset: (number - 1) * per_page, orders: "-publishedAt")
Page.new( contents: list[:contents], total_count: list[:totalCount], total_pages: [1, (list[:totalCount].to_f / per_page).ceil].max, number: number ) endendThe page number comes from the URL, so validate it before calculating offset. to_i turns a nonnumeric string into 0, which could produce a negative offset.
number = [params[:page].to_i, 1].maxpage = Genko.page(number)<nav aria-label="Pagination"> <% if page.previous %> <%= link_to "Previous page", blog_index_path(page: page.previous), rel: "prev" %> <% end %> <span><%= page.number %> / <%= page.total_pages %></span> <% if page.next %> <%= link_to "Next page", blog_index_path(page: page.next), rel: "next" %> <% end %></nav>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 array rather than an error.
Do not fetch every entry in a loop on each page view. Requests per page would grow with the entry count and reach the 300 requests per minute limit. Run jobs that need all entries, such as sitemap or feed generation, as rake tasks or on a schedule and store the result.
def self.all_entries(endpoint, **queries) offset = 0 entries = []
loop do list = get("/#{endpoint}", **queries, limit: 100, offset: offset) entries.concat(list[:contents]) offset += 100 break if offset >= list[:totalCount] end
entriesendIf 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.
class BlogController < ApplicationController def show draft_key = params[:draftKey].presence
entry = Genko.get("/blog/#{ERB::Util.url_encode(params[:id])}", draftKey: draft_key) @post = Post.from_entry(entry)
return unless draft_key
# Keep previews out of search indexes and referrer headers. response.headers["X-Robots-Tag"] = "noindex, nofollow" response.headers["Referrer-Policy"] = "no-referrer" response.headers["Cache-Control"] = "private, no-store" rescue Genko::ApiError => e raise unless e.status == 404
head :not_found endendGenko.get drops nil values with queries.compact, so draftKey: nil is omitted. presence also converts an empty string to nil, because an empty draftKey returns 400.
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. Do not reuse a fetch wrapped in
Rails.cache.fetchfor previews. - 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 Ruby, generate the same value with OpenSSL::HMAC.hexdigest and compare it with OpenSSL.secure_compare.
Verify the signature against the raw request body before parsing JSON. In Rails use request.raw_post; in Rack use request.body.read. params is already parsed, and serializing it again with to_json will not verify because key order and whitespace can differ.
# frozen_string_literal: true
require "openssl"
module Genko module Webhook # genko.me sends `sha256=` followed by the hexadecimal HMAC-SHA256 of `timestamp.rawBody`. def self.valid_signature?(secret, raw_body, timestamp, signature_header) return false unless timestamp&.match?(/\A(0|[1-9][0-9]*)\z/) return false if (Time.now.to_i - timestamp.to_i).abs > 300 return false if signature_header.nil?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")}" # secure_compare prevents timing attacks. Do not compare with ==. OpenSSL.secure_compare(expected, signature_header) end endendHere is a Sinatra receiver.
require "json"require "sinatra/base"require_relative "lib/genko/webhook"
class GenkoWebhook < Sinatra::Base post "/genko-webhook" do secret = ENV.fetch("GENKO_WEBHOOK_SECRET") raw_body = request.body.read timestamp = request.env["HTTP_X_GENKO_TIMESTAMP"] signature = request.env["HTTP_X_GENKO_SIGNATURE"]
halt 401, "Invalid signature" unless Genko::Webhook.valid_signature?(secret, raw_body, timestamp, signature)
payload = JSON.parse(raw_body, symbolize_names: true) entry_id = payload.dig(:entry, :id) # entry is nil for a test delivery logger.info("[genko] #{payload[:event]} #{entry_id || '(none)'}")
# Clear the cache or enqueue a rebuild here. content_type :json { received: true }.to_json endendIn a Rails controller, use request.raw_post and request.headers["X-Genko-Signature"]. Exempt this receiver route from CSRF protection with skip_forgery_protection.
class GenkoWebhooksController < ActionController::API def create secret = Rails.application.credentials.genko_webhook_secret valid = Genko::Webhook.valid_signature?( secret, request.raw_post, request.headers["X-Genko-Timestamp"], request.headers["X-Genko-Signature"] ) return head :unauthorized unless valid
payload = JSON.parse(request.raw_post, symbolize_names: true) # Hand slow work to a job and return from the receiver promptly. GenkoRefreshJob.perform_later(payload[:api], payload.dig(:entry, :id))
render json: { received: true } endendReceiver 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 null. Handle the missing entry |
A synchronous rebuild during the request can exceed the 10-second timeout. Hand it to Active Job or a similar queue 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