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
endapp/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
endrate_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
endafter_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 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
endThe 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
endDeduplicating 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.