Schedule Webhooks from Rails

Schedule delayed tasks and webhooks from Rails without Sidekiq or Redis. Uses Net::HTTP and HMAC-SHA256 — no gem required. Works alongside Sidekiq for the timing-and-delivery subset.

Last updated: March 24, 2026

There is no Ruby SDK for Posthook. The integration uses Net::HTTP and OpenSSL::HMAC — both part of Ruby’s standard library. No gem to install, no dependency to manage. Schedule a hook with a JSON POST, receive the delivery at a Rails controller, verify the signature with HMAC-SHA256.

If you use Sidekiq, you can keep it for in-process background jobs and use Posthook for work that is really just waiting for a timestamp — a reminder in 48 hours, an expiration check in 7 days. This guide shows both patterns: scheduling directly, and scheduling a timed delivery that enqueues a Sidekiq job when it fires.

Setup

Store your API key and signing key in Rails credentials:

rails credentials:edit
posthook_api_key: phk_your_api_key
posthook_signing_key: ph_sk_your_signing_key

Create a service object for scheduling:

# app/services/posthook_client.rb
require "net/http"
require "json"

class PosthookClient
  BASE_URL = "https://api.posthook.io/v1"

  def schedule(path:, data: {}, **timing)
    uri = URI("#{BASE_URL}/hooks")
    body = { path: path, data: data }.merge(timing)

    request = Net::HTTP::Post.new(uri)
    request["Content-Type"] = "application/json"
    request["X-API-Key"] = Rails.application.credentials.posthook_api_key
    request.body = body.to_json

    response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
      http.request(request)
    end

    JSON.parse(response.body)
  end
end

Schedule a hook

When an order is placed, schedule a payment timeout check for 30 minutes later:

class OrdersController < ApplicationController
  def create
    @order = Order.create!(order_params)

    PosthookClient.new.schedule(
      path: "/webhooks/order-timeout",
      postIn: "30m",
      data: { order_id: @order.id }
    )

    render json: { order_id: @order.id }, status: :created
  end
end

The hook is persisted by Posthook. It survives deploys, restarts, and scaling events.

For user-facing reminders, postAtLocal schedules in the user’s timezone and handles DST transitions automatically:

PosthookClient.new.schedule(
  path: "/webhooks/trial-reminder",
  postAtLocal: "2026-04-05T09:00:00",
  timezone: "America/Chicago",
  data: { user_id: user.id }
)

Receive and verify the delivery

The webhook controller verifies the HMAC-SHA256 signature, parses the payload, and decides whether to act. The signature verification is manual — about 15 lines of Ruby:

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def handle
    body = request.raw_post
    unless verify_posthook_signature(body, request.headers)
      head :unauthorized
      return
    end

    payload = JSON.parse(body)
    data = payload["data"]

    case params[:action_name]
    when "order-timeout"
      handle_order_timeout(data)
    when "trial-reminder"
      handle_trial_reminder(data)
    end

    head :ok
  end

  private

  def verify_posthook_signature(body, headers)
    signing_key = Rails.application.credentials.posthook_signing_key
    timestamp = headers["Posthook-Timestamp"]
    signatures = headers["Posthook-Signature"]
    return false unless timestamp && signatures

    # Reject if timestamp is more than 5 minutes old
    return false if (Time.now.to_i - timestamp.to_i).abs > 300

    # Compute expected signature
    signed_payload = "#{timestamp}.#{body}"
    expected = "v1," + OpenSSL::HMAC.hexdigest("SHA256", signing_key, signed_payload)

    # Compare against each space-separated signature (supports key rotation)
    signatures.split(" ").any? do |sig|
      ActiveSupport::SecurityUtils.secure_compare(sig, expected)
    end
  end

  def handle_order_timeout(data)
    order = Order.find(data["order_id"])
    return if order.status != "pending"

    order.update!(status: "expired")
    OrderMailer.payment_timeout(order).deliver_later
  end

  def handle_trial_reminder(data)
    user = User.find(data["user_id"])
    return if user.subscription_active?

    ReminderMailer.trial_expiring(user).deliver_later
  end
end

Add the route:

# config/routes.rb
post "/webhooks/:action_name", to: "webhooks#handle"

The state check in each handler makes them idempotent. Posthook guarantees at-least-once delivery — the handler may run more than once for the same hook. Since handle_order_timeout checks order.status before acting, duplicates resolve as no-ops.

Working alongside Sidekiq

Posthook handles the durable timing. Sidekiq handles in-process execution with full Rails context. The delivery arrives at your Rails endpoint and enqueues a Sidekiq job:

def handle_trial_reminder(data)
  user = User.find(data["user_id"])
  return if user.subscription_active?

  # Posthook handled the timing — Sidekiq handles the work
  SendTrialReminderWorker.perform_async(user.id)
end
# app/workers/send_trial_reminder_worker.rb
class SendTrialReminderWorker
  include Sidekiq::Worker

  def perform(user_id)
    user = User.find(user_id)
    return if user.subscription_active?

    ReminderMailer.trial_expiring(user).deliver_now
    user.update!(trial_reminder_sent_at: Time.current)
  end
end

This keeps scheduled jobs out of Redis sorted sets where they would sit for days consuming memory and adding polling overhead. Posthook schedules the delivery for the right time, Sidekiq processes the work immediately when it arrives.

Local development

Use the Posthook CLI to forward deliveries to your local Rails server — no deploy, no public URL:

npx posthook listen --forward http://localhost:3000

The CLI connects via WebSocket and forwards each delivery as an HTTP POST to your local server with the same headers and payload as production. Your handler’s response determines the outcome — 200 means delivered, 202 starts an async hook flow, and anything else triggers a retry. Retries are handled by Posthook the same way they are in production, with the same backoff and attempt limits. The CLI requires Node.js, but your Rails app just receives standard HTTP requests.

Next steps

Frequently asked questions

Ready to get started?

Create your free account and start scheduling hooks in minutes. No credit card required.