FlexOps Ruby SDK
Official Ruby SDK for the FlexOps multi-carrier shipping platform. Supports USPS, UPS, FedEx, DHL, OnTrac, Australia Post, Canada Post, Royal Mail, and LSO with rate shopping, label generation, tracking, webhooks, wallet, insurance, returns, and more.
Installation
Add to your Gemfile:
gem "flexops"Then:
bundle installOr install directly:
gem install flexopsQuick Start
require "flexops"
# API key authentication (recommended for server-to-server)
client = FlexOps::Client.new(
api_key: "live_...",
workspace_id: "your-workspace-id"
)
# Get shipping rates from all carriers
rates = client.shipping.get_rates(
origin: {addressLine1: "123 Main St", city: "New York", stateProvince: "NY", postalCode: "10001"},
destination: {addressLine1: "456 Oak Ave", city: "Los Angeles", stateProvince: "CA", postalCode: "90210"},
package: {weight: 16, weightUnit: "oz"}
)
request = {
carrierCode: "USPS", serviceCode: "GROUND_ADVANTAGE",
origin: {name: "Warehouse", addressLine1: "123 Main St", city: "New York", stateProvince: "NY", postalCode: "10001", countryCode: "US"},
destination: {name: "Customer", addressLine1: "456 Oak Ave", city: "Los Angeles", stateProvince: "CA", postalCode: "90210", countryCode: "US"},
package: {weight: 16, weightUnit: "oz"},
maximumPostageAmount: 10.25, # Caller-approved ceiling in USD.
# Required without an orderId. The SDK never fills it in; NONE (0) = not hazardous.
hazmatClassification: FlexOps::HazmatClassification::NONE
}
preview = client.shipping.create_label(request)
puts preview["quotedPostageAmount"]
# Call only after explicit caller approval, within five minutes.
purchase_approved_label = lambda do |purchase_key|
client.shipping.create_label(
request.merge(confirmationToken: preview["confirmationToken"]),
idempotency_key: purchase_key # Persist per purchase; reuse on retries.
)
end
# Track a shipment
info = client.shipping.track("9400111899223456789012")Live label approval (1.1.0+)
The example below requires SDK 1.1.0 or later. Version 1.0.2 does not include the per-call idempotency argument.
For live domestic single-label requests, maximumPostageAmount is required: positive
USD, at most two decimal places, up to 1,000,000. Missing or invalid values return
400 ApprovalRequired. Omitting confirmationToken returns a raw 200 preview
(status, quotedPostageAmount, maximumPostageAmount, currency, expiresAt,
confirmationToken). After explicit approval, resubmit the same shipment and ceiling
with the token and a unique per-purchase Idempotency-Key header. The token expires
after five minutes. A successful purchase returns the raw 201 label, including
labelId, trackingNumber, carrierCode, and labelData, without a data wrapper.
Keep the same key and request across retries. An uncertain outcome needs reconciliation;
do not start another purchase with a new key. An expired approval returns 409
ApprovalExpired; obtain and explicitly approve a fresh preview when no purchase is
unresolved. The ceiling bounds postage authorization, not later adjustments or separate
fees. Sandbox keys follow the same approval and buy no postage; batch, return, and raw carrier routes have
separate contracts. The SDK never automatically confirms a preview.
Authentication
API key (recommended)
client = FlexOps::Client.new(api_key: "live_...", workspace_id: "your-workspace-id")Email / password
client = FlexOps::Client.new(base_url: "https://gateway.flexops.io")
session = client.auth.login(email: "user@example.com", password: "password")
client.set_access_token(session["data"]["token"])
client.workspace_id = "your-workspace-id"Sandbox / test keys
Use test_... (instead of live_...) to route to the sandbox environment. Mock carriers respond, nothing hits real carrier APIs, no charges, no real labels. Perfect for CI and integration tests.
client = FlexOps::Client.new(api_key: "test_...", workspace_id: "your-workspace-id")Carrier-specific calls
client.carriers.usps, .ups, .fedex and .dhl call carrier routes through the legacy
/api/ApiProxy passthrough, which the Gateway retires on 2027-06-30.
- Lookups (GET), such as address checks, tracking and DHL rates, work with a user token, or an API
key that has the permission for the data:
shipping:readfor address, ZIP, city, location, service point and reference lookups;tracking:readfor tracking and proof of delivery;rates:readfor delivery standards, rates and products. Without it the Gateway returns 403API_KEY_SCOPE_REQUIRED. - The writes (POST, PUT and DELETE, such as
usps.get_domestic_rates,fedex.cancel_shipmentorups.create_pickup) are deprecated. They need a user token fromauth.login: API keys get 403API_KEY_SCOPE_REQUIRED, and no API-key scope will be added for them. They stop working when the proxy is retired. - Label generation through the proxy is closed (410 Gone), so the SDK has no carrier label method.
Use the native routes instead where they cover the call:
| Instead of | Use |
|---|---|
| Carrier rate calls | client.shipping.get_rates |
| Carrier labels |
client.shipping.create_label (client.returns.generate_label for returns) |
| Label cancels | client.shipping.cancel_label |
| Carrier pickups |
client.pickups.schedule, list, get, cancel
|
| USPS scan forms | client.scan_forms.create |
| Address validation | client.shipping.validate_address |
| Tracking | client.shipping.track |
# USPS city and state for a ZIP code (works with an API key)
place = client.carriers.usps.city_state_lookup("10001")Retries and idempotency
The client retries a request up to three times with exponential backoff:
- GET: after a dropped connection or timeout, and on 429, 500, 502, 503 and 504.
- POST, PUT and PATCH: after a dropped connection or timeout, and on 429, 502, 503, 504 and
409
IDEMPOTENCY_CONFLICT(the first request with that key is still running). Never on 500: the Gateway does not replay a 5xx, so a retry could run the write (and charge the wallet) again. - DELETE: only on 429.
A key reused with a different request body gets 422 IDEMPOTENCY_KEY_REUSED; it is not retried
and raises FlexOps::Error with that code. Error codes are matched exactly, so a label
purchase's own 409 IdempotencyConflict (altered inputs under a used key) is not retried either.
Every POST, PUT and PATCH carries an Idempotency-Key header. Pass your own to
shipping.create_label(request, idempotency_key: ...), insurance.purchase(..., idempotency_key: ...)
or carbon_offsets.purchase(..., idempotency_key: ...), and store it so a retry your app makes after
an error or a timeout reuses it; otherwise the client generates one UUID
per call and sends it on every retry of that call, so the Gateway replays a write it already
finished instead of running it twice. A 2xx response with no body returns nil.
Workspace members
Roles are the Gateway WorkspaceRole numbers (Owner 0, Admin 1, Member 2, Merchant 3). Pass a
FlexOps::WorkspaceRole constant or a name; the SDK sends the number.
client.workspaces.invite_member(email: "ops@example.com", role: :member)
client.workspaces.update_member_role(user_id, FlexOps::WorkspaceRole::ADMIN)
result = client.workspaces.list_members
members = result["data"]["members"] # also "pendingInvites", "userLimit", "canInviteMore"Recurring shipments, report schedules and email templates
Reads work with any API key or a user token. Writes (every POST, PUT and DELETE, including the
email template preview) need a user token from auth.login or an API key granted the
resource's opt-in scope: recurring-shipments:write, report-schedules:write or
email-templates:write. Older keys do not have these scopes; a key without one gets 403
API_KEY_SCOPE_REQUIRED with requiredScopes. Enums are sent as numbers and ids are GUID
strings.
# Recurring shipments (bare JSON). interval: Daily 0, Weekly 1, Biweekly 2, Monthly 3, Custom 4
schedule = client.recurring_shipments.create(
name: "Weekly restock", addressId: address_id, presetId: preset_id,
interval: 1, firstRunUtc: "2026-11-02T14:00:00Z"
)
client.recurring_shipments.pause(schedule["id"]) # nil (204); also resume, delete
client.recurring_shipments.trigger(schedule["id"], idempotency_key: run_key) # creates an order now
# Report schedules ({ success, data, message } envelope).
# format: Csv 0, Json 1; frequencyType: Daily 0, Weekly 1, Monthly 2
report = client.report_schedules.create(
reportType: "shipments", format: 0, frequencyType: 1, dayOfWeek: 1,
timeOfDayUtc: "08:00:00", recipientEmails: "ops@example.com"
)["data"]
client.report_schedules.toggle(report["id"]) # flips isEnabled
# Tracking email templates (bare JSON), one per event. eventType: LabelCreated 0, InTransit 1,
# OutForDelivery 2, Delivered 3, Exception 4, ReturnToSender 5
client.email_templates.upsert(eventType: 3, subjectTemplate: "Delivered: {{tracking_number}}",
htmlBodyTemplate: "<p>Your package arrived.</p>")
client.email_templates.preview(subject_template: "Hi {{recipient_name}}", html_body_template: "<p>{{status}}</p>")Multi-package labels
One order shipped in several boxes with the same carrier and service; each box gets its own label and
a box that fails does not stop the others. Multi-package labels cannot carry HAZMAT: send
FlexOps::HazmatClassification::NONE or leave hazmatClassification out. maximumPostageAmount is required: the
most the whole shipment may cost in USD (positive, at most two decimals). The Gateway refuses to buy a box that would
take the total above it (PriceCeilingExceeded; once the amount is used up the remaining boxes report
PriceCeilingReached), and a request without a valid ceiling is 400 ApprovalRequired.
begin
shipment = client.shipping.create_multi_package(
{ orderId: 12345, carrierCode: "USPS", serviceCode: "PRIORITY",
hazmatClassification: FlexOps::HazmatClassification::NONE,
maximumPostageAmount: 25.00, # required: the most the whole shipment may cost, in USD
packages: [{ weightInOunces: 16, lengthInInches: 10, widthInInches: 8, heightInInches: 4, packageReference: "Box 1 of 2" },
{ weightInOunces: 8, lengthInInches: 6, widthInInches: 6, heightInInches: 6, packageReference: "Box 2 of 2" }] },
idempotency_key: "order-12345-boxes" # optional; reuse it when you retry
)
rescue FlexOps::Error => e
# No label was bought: 422 AllPackagesFailed, or 409 OutcomeUnknown when a label may exist
# (check the order before buying again). e.data holds the per-box results.
endclient.returns.get reads a received return by its numeric return id; read an RMA with
client.returns.get_rma("RMA-1001") or page through them with client.returns.list_rmas.
Wallet balance
balance = client.wallet.get_balance # {"hasWallet" => true, "balanceCents" => 1250, "balance" => 12.5}The request carries X-Workspace-Id set to the client's workspace_id. Funding and
transaction history are managed in the FlexOps portal.
Webhook subscriptions
Managing webhooks needs a signed-in user's access token. The Gateway refuses API keys on every webhook route, reads included (list and list_delivery_logs as well as create, update, delete, rotate_secret and test), with 403 SIGNED_IN_USER_REQUIRED, whatever the key's scopes; the SDK raises FlexOps::Error with that code. Reads are refused too because webhook delivery logs carry each delivery's payload (order and customer data). A client with an API key sends the key even when an access token is also set, so use a client created without api_key. Verifying deliveries (below) makes no request and needs no credentials.
# Sign in as a user (or pass access_token:); an API key gets 403 SIGNED_IN_USER_REQUIRED here.
client = FlexOps::Client.new(workspace_id: "your-workspace-id")
session = client.auth.login(email: "user@example.com", password: "password")
client.set_access_token(session["data"]["token"])
# Leave events out to receive every event.
webhook = client.webhooks.create(
url: "https://your-app.com/webhooks/flexops",
events: [FlexOps::WebhookEventType::LABEL_CREATED,
FlexOps::WebhookEventType::LABEL_CANCELED,
FlexOps::WebhookEventType::BATCH_COMPLETED]
)
signing_secret = webhook["secret"] # "whsec_...", returned only on create; store it
# Change the events (or url, description, status: "Active", "Paused", "Disabled").
client.webhooks.update(webhook["id"], events: [FlexOps::WebhookEventType::LABEL_CREATED])create returns the subscription itself ("id", "url", "eventTypes", "status", "secret", ...), because the Gateway answers it with the bare subscription. The other webhook methods return the usual {"success" => true, "data" => ...} envelope.
events is sent as the Gateway's comma-separated eventTypes. FlexOps sends these events, named exactly as below (FlexOps::WebhookEventType::ALL lists them). Names are PascalCase and matched exactly, so a name like label.created would never match; create and update raise ArgumentError, before any request, for an unknown name, an empty list or a non-array.
| Event | Sent when |
|---|---|
LabelCreated |
A live label is purchased (sandbox labels send nothing) |
LabelCanceled |
A live label is voided |
BatchCompleted |
A batch label job finishes |
TrackingUpdated |
A USPS tracking scan arrives (USPS push-tracking pilot workspaces only) |
TrackingDelivered |
A USPS package is delivered (USPS push-tracking pilot workspaces only) |
You can send a raw eventTypes: string ("*" or names joined by commas with no spaces) instead of events:; it is passed through unchecked, for example to subscribe to an event newer than your SDK version.
Webhook verification
FlexOps signs each delivery with HMAC-SHA256 over {X-FlexOps-Timestamp}.{raw body}, keyed with your subscription secret (whsec_..., used as-is), and sends it as X-FlexOps-Signature: sha256=<hex>. Verify the raw body before you parse it.
# Rails controller action
def flexops_webhook
payload = request.raw_post
valid = FlexOps::Resources::Webhooks.verify_signature(
payload: payload,
signature: request.headers["X-FlexOps-Signature"], # "sha256=..."
timestamp: request.headers["X-FlexOps-Timestamp"], # Unix seconds
secret: ENV.fetch("FLEXOPS_WEBHOOK_SECRET") # "whsec_..."
)
return head :unauthorized unless valid
# The body is the event data itself (no type/data envelope).
# The event name is in a header; test events send "test".
data = JSON.parse(payload)
case request.headers["X-FlexOps-Event"]
when "test"
# data: {"type"=>"test", "message"=>"...", "timestamp"=>"...", "subscriptionId"=>"..."}
when "TrackingUpdated"
# data["trackingNumber"], data["status"], ...
end
head :ok
endDeliveries whose timestamp is more than 300 seconds from your clock (either way) are rejected to limit replays; pass tolerance: (seconds) to change that. verify_signature! takes the same arguments and raises FlexOps::WebhookSignatureError instead of returning false; its code says why (WEBHOOK_SIGNATURE_MISMATCH, WEBHOOK_TIMESTAMP_OUTSIDE_TOLERANCE, WEBHOOK_INVALID_TIMESTAMP, WEBHOOK_MISSING_SIGNATURE, WEBHOOK_MISSING_SECRET).
Curl quickstart
Every SDK method is a thin wrapper around the FlexOps REST API. If you want to verify the API before committing to the SDK — or you're integrating from a language we don't ship a SDK for — these curl invocations hit the same endpoints:
# Shop rates across all connected carriers
curl -X POST https://gateway.flexops.io/api/shipping/rates \
-H "X-API-Key: live_..." \
-H "Content-Type: application/json" \
-d '{
"origin": {"addressLine1": "123 Main St", "city": "New York", "stateProvince": "NY", "postalCode": "10001", "countryCode": "US"},
"destination": {"addressLine1": "456 Oak Ave", "city": "Los Angeles", "stateProvince": "CA", "postalCode": "90210", "countryCode": "US"},
"package": {"weight": 16, "weightUnit": "oz"}
}'
# Preview a live label; this does not purchase it.
# hazmatClassification 0 = not hazardous; required when there is no orderId.
curl -X POST https://gateway.flexops.io/api/shipping/labels \
-H "X-API-Key: live_..." \
-H "Content-Type: application/json" \
-d '{
"carrierCode": "USPS", "serviceCode": "GROUND_ADVANTAGE",
"origin": {"name": "Warehouse", "addressLine1": "123 Main St", "city": "New York", "stateProvince": "NY", "postalCode": "10001", "countryCode": "US"},
"destination": {"name": "Customer", "addressLine1": "456 Oak Ave", "city": "Los Angeles", "stateProvince": "CA", "postalCode": "90210", "countryCode": "US"},
"package": {"weight": 16, "weightUnit": "oz"},
"maximumPostageAmount": 10.25,
"hazmatClassification": 0
}'
# After explicit approval, resend the same body with confirmationToken from
# the preview and an Idempotency-Key header unique to this purchase.
# See the SDK example above; retain that key for retries.
# Track a shipment
curl https://gateway.flexops.io/api/shipping/track/9400111899223456789012 \
-H "X-API-Key: live_..."
# Cancel a label
curl -X DELETE "https://gateway.flexops.io/api/shipping/labels/lbl_123?carrierCode=USPS" \
-H "X-API-Key: live_..."Use a test_... key instead of live_... to hit the sandbox environment; mock carriers respond, no real charges, no real labels.
Resources
| Resource | Description |
|---|---|
client.auth |
Login, token refresh, logout |
client.workspaces |
List and read workspaces; list, invite and remove members, change roles |
client.shipping |
Rate shopping, labels, multi-package labels, tracking, address validation, batch, cancel |
client.carriers |
USPS, UPS, FedEx and DHL calls through the legacy proxy; writes are deprecated and need a user token |
client.webhooks |
Subscription create/update/delete, secret rotation, test events, delivery logs, signature verification |
client.wallet |
Workspace balance |
client.insurance |
Providers, quotes, policies, void, claims |
client.returns |
List and read received returns; create, read and list RMAs; return labels; receive |
client.analytics |
Shipments, orders, carrier performance (legacy proxy reads; API keys need each method's read permission) |
client.orders |
Order reads (legacy proxy; API keys need orders:read) |
client.inventory |
Inventory reads (legacy proxy; API keys need inventory:read) |
client.pickups |
Schedule, list, read and cancel carrier pickups |
client.scan_forms |
USPS scan forms |
client.rules |
Shipping rules: create, read, update, delete, reorder, test |
client.hs_codes |
HS code search and lookup, landed cost |
client.carbon_offsets |
Carbon offset quotes, purchases, summary and settings |
client.recurring_shipments |
Recurring shipment schedules: create, update, pause, resume, trigger, delete |
client.report_schedules |
Scheduled report emails: create, update, toggle, delete |
client.email_templates |
Tracking email templates: upsert, preview, delete |
client.analytics, client.orders and client.inventory read through the legacy proxy. With an
API key they need the permission for the data they read: orders:read for orders,
inventory:read for inventory, and for analytics the permission named on each method
(shipping:read, inventory:read, orders:read or returns:read). A key without it gets 403
API_KEY_SCOPE_REQUIRED. Keys created with the default permissions have all of these.
API keys, workspace settings and account management are managed in the FlexOps portals.
Configuration
client = FlexOps::Client.new(
base_url: "https://gateway.flexops.io", # API base URL
api_key: "live_...", # API key auth
workspace_id: "your-workspace-id", # Default workspace
timeout: 30 # Request timeout (seconds)
)Requirements
- Ruby 3.1+
License
MIT © FlexOps, LLC. See LICENSE for full text.