# Rails + PrintedSimply

Source: https://portal.printedsimply.com/docs/quickstarts/rails

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.

$0 sandbox key

Idempotent orders

Signed webhooks

1. Install Dependencies

```
# 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_SECRET
```

2. Create the API Client

A 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
```

3. Add the Controller

```
# 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
end
```

Routes:

```
# 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"
end
```

4. Handle Webhooks

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

5. Run It

```
bundle install && rails server
```

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