Recipe: Rails integration
A service object that places orders, a controller that receives + verifies webhooks, and the routes to glue them together. Standard Rails idioms — drop-in for Sinatra too with minor changes.
What you'll build
Three files. Each one does exactly one job.
app/services/verdacert_client.rb— thin client that quotes + submits with reproducible idempotency.app/controllers/verdacert_webhooks_controller.rb— HMAC-verified receiver that dispatches to a background job.config/routes.rb— one route line.
Environment
Two secrets, both server-side.
# .env or Rails credentials (NEVER commit raw values)
VERDACERT_API_KEY=vc_sandbox_…
VERDACERT_WEBHOOK_SECRET=… # from /portal/api when you set the webhook URLThe client (service object)
Wraps quote + submit so controllers stay one-liners.
# app/services/verdacert_client.rb
require "net/http"
require "json"
require "uri"
require "digest"
class VerdacertClient
BASE = "https://verdacert.com/api/v1".freeze
def initialize(api_key: ENV.fetch("VERDACERT_API_KEY"))
@api_key = api_key
end
# Returns the Verdacert jobId. Idempotent — same args ⇒ same job.
def place_order(source_language:, use_case:, page_count:, document_url:, end_user_external_id:)
quote_input = {
sourceLanguage: source_language,
useCase: use_case,
pageCount: page_count,
speedTier: "standard",
}
quote = post_json("/quote", quote_input)
raise quote["error"]["message"] if quote["error"]
# Reproducible key — same inputs ⇒ same idempotencyKey ⇒ same order on retry.
idempotency_key =
"ord_" +
Digest::SHA256.hexdigest("#{end_user_external_id}.#{document_url}")[0, 24]
submit = post_json("/submit", {
quoteId: quote["quoteId"],
idempotencyKey: idempotency_key,
echoedInput: quote_input,
documents: [{ url: document_url }],
endUser: { externalId: end_user_external_id },
})
raise submit["error"]["message"] if submit["error"]
submit
end
def status(job_id)
get_json("/status/#{job_id}")
end
def result(job_id)
get_json("/result/#{job_id}")
end
private
def post_json(path, body)
req = Net::HTTP::Post.new(URI("#{BASE}#{path}"), headers)
req.body = body.to_json
do_request(req)
end
def get_json(path)
do_request(Net::HTTP::Get.new(URI("#{BASE}#{path}"), headers))
end
def headers
{
"Authorization" => "Bearer #{@api_key}",
"Content-Type" => "application/json",
"Accept" => "application/json",
}
end
def do_request(req)
uri = req.uri
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
JSON.parse(http.request(req).body)
end
end
endThe webhook receiver
Reads the raw body, verifies HMAC in constant time, dedups on event_id, enqueues a job.
# app/controllers/verdacert_webhooks_controller.rb
class VerdacertWebhooksController < ApplicationController
# Webhook receivers don't carry CSRF tokens — skip the protection.
skip_before_action :verify_authenticity_token
TOLERANCE_SECONDS = 5 * 60
def create
raw = request.raw_post
sig_hdr = request.headers["x-verdacert-signature"].to_s
event_id = request.headers["x-verdacert-event-id"].to_s
return head :bad_request unless verify_signature(raw, sig_hdr)
# Dedup BEFORE enqueuing work — at-least-once delivery means duplicates.
if WebhookEvent.exists?(provider: "verdacert", event_id: event_id)
return head :ok
end
event = JSON.parse(raw)
WebhookEvent.create!(
provider: "verdacert",
event_id: event_id,
event_type: event["type"],
payload: event,
)
VerdacertEventJob.perform_later(event_id)
head :ok # Ack fast; the job does the heavy work.
end
private
def verify_signature(raw_body, sig_header)
parts = sig_header.split(",").map { |p| p.strip.split("=", 2) }.to_h
t = parts["t"].to_i
v1 = parts["v1"].to_s
return false if t.zero? || v1.empty?
return false if (Time.now.to_i - t).abs > TOLERANCE_SECONDS
secret = ENV.fetch("VERDACERT_WEBHOOK_SECRET")
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
OpenSSL.fixed_length_secure_compare(expected, v1)
rescue ArgumentError
false # length mismatch — fixed_length_secure_compare raises
end
endparamsby default, which re-serializes — and the resulting bytes don't match what we signed. Always verify against request.raw_post. Same gotcha applies in any framework with a JSON body parser.The background job
Where you'd notify your user, kick off downstream pipelines, etc.
# app/jobs/verdacert_event_job.rb
class VerdacertEventJob < ApplicationJob
queue_as :default
def perform(event_id)
row = WebhookEvent.find_by(provider: "verdacert", event_id: event_id)
return unless row
case row.event_type
when "order.ready"
order = Order.find_by(verdacert_job_id: row.payload["data"]["jobId"])
return unless order
OrderMailer.with(order: order).ready_email.deliver_later
when "order.failed"
AdminMailer.with(payload: row.payload).failed_alert.deliver_later
# ... handle other event types ...
end
end
endWire it up
One route, one controller line on your existing orders flow.
# config/routes.rb
post "/webhooks/verdacert", to: "verdacert_webhooks#create"# app/controllers/orders_controller.rb
def create
result = VerdacertClient.new.place_order(
source_language: params[:source_language],
use_case: params[:use_case],
page_count: params[:page_count].to_i,
document_url: params[:document_url],
end_user_external_id: current_user.id.to_s,
)
order = current_user.orders.create!(verdacert_job_id: result["jobId"])
redirect_to order_path(order)
rescue => e
flash[:error] = e.message
redirect_to new_order_path
endTell Verdacert where to deliver
In /portal/api, paste the public URL for your receiver.
In dev, expose localhost:3000 via ngrok http 3000 or cloudflared tunnel --url http://localhost:3000 and paste that URL into the portal. Save the signing secret shown once — that's your VERDACERT_WEBHOOK_SECRET.
