API documentation

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 URL

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

The 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
end
Why request.raw_post and not params?
Rails parses JSON bodies into paramsby 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
end

Wire 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
end

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

Where to next

Get instant quotePricing