Countdown Timer API with Ruby on Rails

A service object using only the standard library, an ActiveJob worker for the webhook payload, and a controller that verifies signatures correctly. Two Rails-specific traps get their own treatment: forgery protection rejecting the webhook before your action runs, and request.raw_post versus params.

The service object

No gems needed. Net::HTTP is verbose but has no dependency cost, and the shape ports directly to Faraday if you already use it.

app/services/countdown.rb — public interface

# app/services/countdown.rb — standard library only.
require "net/http"
require "json"

class Countdown
  BASE = URI("https://countdownshare.com/api/v1")
  RETRYABLE = %w[rate_limited internal_error].freeze

  Error = Class.new(StandardError) do
    attr_reader :code, :status, :request_id, :retry_after

    def initialize(code:, message:, status:, request_id:, retry_after: nil)
      @code = code
      @status = status
      @request_id = request_id
      @retry_after = retry_after
      super("#{code}: #{message} (#{request_id})")
    end
  end

  def initialize(api_key: ENV.fetch("COUNTDOWNSHARE_API_KEY"))
    @api_key = api_key
  end

  def create_timer(body, idempotency_key:)
    with_retry do
      post("/timers", body, "Idempotency-Key" => idempotency_key)
    end
  end

  def status(timer_id)
    get("/timers/#{timer_id}/status")
  end

  def outputs(timer_id)
    get("/timers/#{timer_id}/outputs")
  end

  private

  def with_retry(attempts: 4)
    attempt = 0
    begin
      yield
    rescue Error => e
      attempt += 1
      raise if !RETRYABLE.include?(e.code) || attempt >= attempts

      sleep(e.retry_after || [2**attempt * 0.5, 8].min)
      retry
    end
  end

app/services/countdown.rb — transport

  def get(path)
    request(Net::HTTP::Get.new(uri_for(path)))
  end

  def post(path, body, headers = {})
    req = Net::HTTP::Post.new(uri_for(path))
    headers.each { |k, v| req[k] = v }
    req.body = body.to_json
    request(req)
  end

  def uri_for(path)
    URI("#{BASE}#{path}")
  end

  def request(req)
    req["Authorization"] = "Bearer #{@api_key}"
    req["Content-Type"] = "application/json"

    response = Net::HTTP.start(
      req.uri.host, req.uri.port, use_ssl: true, read_timeout: 10
    ) { |http| http.request(req) }

    payload = JSON.parse(response.body)

    unless response.is_a?(Net::HTTPSuccess)
      raise Error.new(
        code: payload.dig("error", "code"),
        message: payload.dig("error", "message"),
        status: response.code.to_i,
        request_id: payload["request_id"],
        retry_after: response["Retry-After"]&.to_i,
      )
    end

    payload["data"]
  end
end
Only rate_limited and internal_error retry. quota_exhausted is also a 429 but the monthly allowance does not refill until the billing cycle resets, so retrying it just burns requests — see error codes.

Creating a timer

app/models/subscription.rb

# app/models/subscription.rb
class Subscription < ApplicationRecord
  after_create_commit :start_trial_countdown

  private

  def start_trial_countdown
    timer = Countdown.new.create_timer(
      {
        name: "Trial — #{user.email}",
        type: "personalized",
        duration_seconds: 14.days.to_i,
        external_user_id: user.id.to_s,
        publish: true,
      },
      # Derived from the record, so a retried callback returns the
      # original timer rather than granting a second trial.
      idempotency_key: "trial_#{id}",
    )

    update_column(:countdown_timer_id, timer["id"])
  end
end

after_create_commit rather than after_create is deliberate. Creating the timer inside the transaction means a later rollback leaves a timer with no record pointing at it — and it counts against your monthly allowance regardless.

The idempotency key is derived from the record ID. If the callback runs twice — a retried job, a replayed message — the original timer comes back instead of a second fourteen-day trial. Idempotency covers why a random key defeats this.

The webhook controller

config/routes.rb

# config/routes.rb
post "/webhooks/countdownshare", to: "countdown_webhooks#create"

app/controllers/countdown_webhooks_controller.rb

# app/controllers/countdown_webhooks_controller.rb
class CountdownWebhooksController < ActionController::API
  # ActionController::API has no forgery protection, which is what
  # you want here. If you inherit from ApplicationController instead,
  # add: skip_before_action :verify_authenticity_token

  def create
    # request.raw_post is the RAW body. params is parsed and
    # cannot be turned back into the bytes that were signed.
    raw = request.raw_post

    return head :unauthorized unless verified?(raw)

    event = JSON.parse(raw)

    # Enqueue and return. The delivery is treated as a timeout
    # after ten seconds.
    HandleCountdownEventJob.perform_later(event)

    head :ok
  end

  private

  def verified?(raw)
    timestamp = request.headers["X-CountdownShare-Timestamp"]
    signature = request.headers["X-CountdownShare-Signature"]
    return false if timestamp.blank? || signature.blank?

    # Reject anything older than five minutes.
    return false if (Time.now.to_i - timestamp.to_i).abs > 300

    expected = OpenSSL::HMAC.hexdigest(
      "SHA256",
      Rails.application.credentials.countdownshare_webhook_secret,
      "#{timestamp}.#{raw}",
    )

    # secure_compare is constant time; == is not.
    ActiveSupport::SecurityUtils.secure_compare(
      expected, signature.delete_prefix("v1=")
    )
  end
end

The two Rails-specific details

Inheriting from ActionController::API avoids forgery protection entirely, which is the cleanest option for a webhook endpoint. If you inherit from ApplicationController instead, skip_before_action :verify_authenticity_token is required or Rails returns 422 before your action ever runs.

And request.raw_post is the raw body. params has already been parsed, and a re-serialised hash has different whitespace and key ordering than what was signed — so the HMAC can never match. Verify first, parse second.

ActiveSupport::SecurityUtils.secure_compare rather than ==. String comparison short-circuits on the first differing byte, which leaks how much of a guessed signature was right.

The job

app/jobs/handle_countdown_event_job.rb

# app/jobs/handle_countdown_event_job.rb
class HandleCountdownEventJob < ApplicationJob
  queue_as :default

  def perform(event)
    # Delivery is at-least-once: the same event id can arrive twice
    # after a retry or a manual replay. A uniqueness record makes
    # the second one a no-op.
    return unless ProcessedEvent.create_unique(event["id"])

    return unless event.dig("data", "status") == "ended"

    subscription = Subscription.find_by(countdown_timer_id: event.dig("timer", "id"))
    subscription&.downgrade!
  end
end

Deduplicating on event["id"] matters because delivery is at-least-once — a retry after a timeout on your side, or a manual replay, resends the same event ID. A unique index on that column makes the check atomic.

Showing the countdown

ERB view and mailer

<%# app/views/subscriptions/show.html.erb %>
<% outputs = Rails.cache.fetch("countdown/#{@subscription.countdown_timer_id}", expires_in: 1.hour) do
     Countdown.new.outputs(@subscription.countdown_timer_id)
   end %>

<% if outputs["website_embed_html"] %>
  <%= raw outputs["website_embed_html"] %>
<% end %>

<%# In a Mailer view, the email GIF: %>
<%= raw outputs["email_embed_html"] %>

Caching the outputs call is worth doing — the URLs are stable for a published timer, and it is the content behind them that updates every time an inbox or a browser requests it. See embeds and hosted pages.

Common questions

Why does my Rails webhook return 422?

Forgery protection. An external POST has no authenticity token, so ApplicationController rejects it before your action runs. Inherit from ActionController::API, which has no forgery protection, or add skip_before_action :verify_authenticity_token. The HMAC signature is doing the authentication.

Why does verification fail when the secret is correct?

Use request.raw_post, not params. Rails parses the JSON body into params before your action runs, and re-serialising that hash produces different whitespace and key ordering than the bytes that were signed. raw_post is the only thing the HMAC will match.

Is there a gem?

No. There are no SDKs in any language. The service object here uses only the standard library, so there is nothing to add to your Gemfile. If you prefer Faraday or HTTParty, the structure ports directly — only the transport lines change.

Where should the timer creation live?

An after_create_commit callback is fine for simple cases, as shown. For anything more involved put it in a service object or a job so a slow or failing API call cannot block the request or roll back the transaction. Commit-time callbacks matter here: creating the timer inside the transaction means a rollback leaves an orphaned timer.

Next steps

No gem to add

Sandbox is free with any account. Drop the service object into app/services, set the environment variable, and it works.