genko.meDocs
Frameworks and languagesRuby

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.

lib/genko/page.rb
# 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    )  endend

The 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.

ruby
number = [params[:page].to_i, 1].maxpage = Genko.page(number)
app/views/blog/index.html.erb
<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.

When you need all entries (scheduled job)
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
  entriesend

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.

app/controllers/blog_controller.rb
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  endend

Genko.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).

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. Do not reuse a fetch wrapped in Rails.cache.fetch for previews.
  • 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 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.

lib/genko/webhook.rb
# 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  endend

Here is a Sinatra receiver.

app.rb
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  endend

In a Rails controller, use request.raw_post and request.headers["X-Genko-Signature"]. Exempt this receiver route from CSRF protection with skip_forgery_protection.

app/controllers/genko_webhooks_controller.rb
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 }  endend

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 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.

Last updated: October 14, 2026

On this page