Integrate PrintedSimply into a Rails app with a service class, controller, routes, and real HMAC webhook verification. Plain REST, no SDK required — live on a $0 test key.
# Gemfile
gem "httparty"
gem "dotenv-rails"Add your API key to .env:
# .env
PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY
PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRETA service class that wraps all PrintedSimply API calls:
# app/services/printed_simply.rb
class PrintedSimply
BASE_URL = "https://portal.printedsimply.com/api/v1"
def initialize(api_key = ENV["PRINTEDSIMPLY_API_KEY"])
@api_key = api_key
end
def products
get("/products")
end
def product(sku)
get("/products/#{sku}")
end
def estimate(items:, shipping_method: "standard")
post("/pricing/estimate", {
items: items,
shipping_method: shipping_method,
})
end
# reference is your own order id. It is stamped on the order, and it makes
# the Idempotency-Key: a retry of the same order returns the first result
# instead of placing a second one.
def create_order(reference:, items:, address_to:, shipping_method: "standard")
post("/order", {
reference: reference,
items: items,
address_to: address_to,
shipping: { method: shipping_method },
}, idempotency_key: "order-#{reference}")
end
def order(id)
get("/order/#{id}")
end
private
def headers
{ "x-api-key" => @api_key, "Content-Type" => "application/json" }
end
def get(path)
HTTParty.get("#{BASE_URL}#{path}", headers: headers).parsed_response
end
def post(path, body, idempotency_key: nil)
request_headers = headers
request_headers = request_headers.merge("Idempotency-Key" => idempotency_key) if idempotency_key
HTTParty.post("#{BASE_URL}#{path}", headers: request_headers, body: body.to_json).parsed_response
end
end# app/controllers/api/orders_controller.rb
class Api::OrdersController < ApplicationController
skip_before_action :verify_authenticity_token
def index_products
render json: ps.products
end
def estimate
result = ps.estimate(
items: params[:items],
shipping_method: params[:shipping_method] || "standard",
)
render json: result
end
def create
order = ps.create_order(
reference: order_params.require(:reference),
items: order_params[:items],
address_to: order_params[:address_to],
shipping_method: order_params[:shipping_method] || "standard",
)
render json: order, status: :created
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def show
render json: ps.order(params[:id])
end
private
def ps
@ps ||= PrintedSimply.new
end
def order_params
params.permit(
:reference,
:shipping_method,
items: [:sku, :quantity, :print_file],
address_to: [:name, :address1, :address2, :city, :state, :zip, :country],
)
end
endRoutes:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
get "products", to: "orders#index_products"
post "estimate", to: "orders#estimate"
post "orders", to: "orders#create"
get "orders/:id", to: "orders#show"
end
post "webhooks/printedsimply", to: "webhooks#printedsimply"
endThe controller verifies X-PS-Signature-V2 over the raw body and rejects a stale timestamp, so a captured delivery cannot be replayed; skips an X-PS-Event-Id it has already handled; and reads the order from the event's data. The route is already in config/routes.rb above.
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
# Delivery is at-least-once: a retry or a redelivery sends the same event
# again, with the same X-PS-Event-Id. Skip an id you have already handled.
# In production, keep handled ids in your database under a unique index;
# this set stands in for it.
HANDLED = Set.new
def printedsimply
# The raw bytes, exactly as signed — not params.
raw_body = request.raw_post
signature = request.headers["X-PS-Signature-V2"]
unless verify_webhook(raw_body, signature, ENV.fetch("PRINTEDSIMPLY_WEBHOOK_SECRET"))
return head :unauthorized
end
event_id = request.headers["X-PS-Event-Id"].to_s
return head :ok if HANDLED.include?(event_id)
# Every delivery is { "id", "event", "timestamp", "data" }; the order is in data.
event = JSON.parse(raw_body)
order = event.fetch("data", {})
case event["event"]
when "order.shipped"
Rails.logger.info "Shipped! Order #{order['order_id']}, tracking #{order["tracking_number"]}"
# Update your database
when "order.delivered"
Rails.logger.info "Delivered! Order #{order['order_id']}"
end
# Answer 200 to events you do not handle too: anything else is a failed delivery.
HANDLED.add(event_id)
head :ok
end
private
TOLERANCE_SECONDS = 300
# header is the raw X-PS-Signature-V2 value, e.g. "t=1754222400,v1=abc123..."
# During a secret rotation the header carries one v1 per live secret
# ("t=...,v1=<new>,v1=<old>"), so collect them all and accept ANY match.
# A missing header (nil) is refused like a malformed one.
def verify_webhook(raw_body, header, secret)
t = ""
v1s = []
header.to_s.split(",").each do |part|
key, value = part.strip.split("=", 2)
next if value.nil?
if key == "t"
t = value
elsif key == "v1"
v1s << value
end
end
return false unless t.match?(/\A\d+\z/) && v1s.any?
# Reject a captured request replayed outside the window. This check is the
# reason to prefer V2 — a body-only signature never expires.
return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS
# The timestamp is signed along with the body, so it cannot be swapped out.
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
v1s.any? { |v1| ActiveSupport::SecurityUtils.secure_compare(v1, expected) }
end
endbundle install && rails serverTest with curl: curl http://localhost:3000/api/products
Safe retries, $0 to test
Send an Idempotency-Key on POST /order so a retry never double-charges. On an ps_test_ key the full order lifecycle runs for $0.