flexops 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7266826af89b91d17327ba0dda3a7318081657cd7d63805be1c7a2665ffcc69c
4
- data.tar.gz: ab7acc1c279b73e6c84dcf57513368d28f5b85386928260bc23b806226f2fd85
3
+ metadata.gz: 65a0d6f9908c2d73479d5a3b2987667357ffadb0849abd7091f2c56aa37ce3be
4
+ data.tar.gz: f1fcc33bd4e7dd7d1c46a181c04256f1a080aba0f9aaca008e2180d77872a05a
5
5
  SHA512:
6
- metadata.gz: '08dcd0f4f95c49a14ef83017a81352a4d5f3e9144ec9724b2af6d86882154d1abf7efd54eb2a9dfaa5a8509871c6686f428b90dc13b8834ef605315ff00fc6bd'
7
- data.tar.gz: 6a854d05d30916be65b30cca8d6c03873bf8f5174df48721c1193c6c54d8aa5cd742767d285b9fd3d8a96c37dff72ac0ca99213d533c926f5302b8d0eaf3bf95
6
+ metadata.gz: 789eeda78864db092ec75eba25d613c43dc2e9914976ed030784df0f854ccd3fa07d0c3fd7f028c51d5acb7a3b2c1a618a5d27a4b249557d5313251203a67045
7
+ data.tar.gz: 5f1b2001938eb3d2ed9fe3b5ad97eaca4b558a5e74fca674740c3d3c993003f8cfb90296723f7b4a58677e33d7bc92c55e2ffe25958780789b40a8aebfdcc932
data/README.md CHANGED
@@ -82,7 +82,7 @@ Keep the same key and request across retries. An uncertain outcome needs reconci
82
82
  do not start another purchase with a new key. An expired approval returns 409
83
83
  `ApprovalExpired`; obtain and explicitly approve a fresh preview when no purchase is
84
84
  unresolved. The ceiling bounds postage authorization, not later adjustments or separate
85
- fees. Sandbox execution bypasses approval; batch, return, and raw carrier routes have
85
+ fees. Sandbox keys follow the same approval and buy no postage; batch, return, and raw carrier routes have
86
86
  separate contracts. The SDK never automatically confirms a preview.
87
87
 
88
88
  ## Authentication
@@ -115,8 +115,11 @@ client = FlexOps::Client.new(api_key: "test_...", workspace_id: "your-workspace-
115
115
  `client.carriers.usps`, `.ups`, `.fedex` and `.dhl` call carrier routes through the legacy
116
116
  `/api/ApiProxy` passthrough, which the Gateway retires on 2027-06-30.
117
117
 
118
- - Lookups (GET), such as address checks, tracking and DHL rates, work with an API key or a user
119
- token.
118
+ - Lookups (GET), such as address checks, tracking and DHL rates, work with a user token, or an API
119
+ key that has the permission for the data: `shipping:read` for address, ZIP, city, location,
120
+ service point and reference lookups; `tracking:read` for tracking and proof of delivery;
121
+ `rates:read` for delivery standards, rates and products. Without it the Gateway returns 403
122
+ `API_KEY_SCOPE_REQUIRED`.
120
123
  - The writes (POST, PUT and DELETE, such as `usps.get_domestic_rates`, `fedex.cancel_shipment`
121
124
  or `ups.create_pickup`) are deprecated. They need a user token from `auth.login`: API keys get
122
125
  403 `API_KEY_SCOPE_REQUIRED`, and no API-key scope will be added for them. They stop working when
@@ -248,7 +251,14 @@ transaction history are managed in the FlexOps portal.
248
251
 
249
252
  ## Webhook subscriptions
250
253
 
254
+ 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.
255
+
251
256
  ```ruby
257
+ # Sign in as a user (or pass access_token:); an API key gets 403 SIGNED_IN_USER_REQUIRED here.
258
+ client = FlexOps::Client.new(workspace_id: "your-workspace-id")
259
+ session = client.auth.login(email: "user@example.com", password: "password")
260
+ client.set_access_token(session["data"]["token"])
261
+
252
262
  # Leave events out to receive every event.
253
263
  webhook = client.webhooks.create(
254
264
  url: "https://your-app.com/webhooks/flexops",
@@ -364,9 +374,9 @@ Use a `test_...` key instead of `live_...` to hit the sandbox environment; mock
364
374
  | `client.wallet` | Workspace balance |
365
375
  | `client.insurance` | Providers, quotes, policies, void, claims |
366
376
  | `client.returns` | List and read received returns; create, read and list RMAs; return labels; receive |
367
- | `client.analytics` | Shipments, orders, carrier performance (legacy proxy reads) |
368
- | `client.orders` | Order reads (legacy proxy) |
369
- | `client.inventory` | Inventory reads (legacy proxy) |
377
+ | `client.analytics` | Shipments, orders, carrier performance (legacy proxy reads; API keys need each method's read permission) |
378
+ | `client.orders` | Order reads (legacy proxy; API keys need `orders:read`) |
379
+ | `client.inventory` | Inventory reads (legacy proxy; API keys need `inventory:read`) |
370
380
  | `client.pickups` | Schedule, list, read and cancel carrier pickups |
371
381
  | `client.scan_forms` | USPS scan forms |
372
382
  | `client.rules` | Shipping rules: create, read, update, delete, reorder, test |
@@ -376,6 +386,12 @@ Use a `test_...` key instead of `live_...` to hit the sandbox environment; mock
376
386
  | `client.report_schedules` | Scheduled report emails: create, update, toggle, delete |
377
387
  | `client.email_templates` | Tracking email templates: upsert, preview, delete |
378
388
 
389
+ `client.analytics`, `client.orders` and `client.inventory` read through the legacy proxy. With an
390
+ API key they need the permission for the data they read: `orders:read` for orders,
391
+ `inventory:read` for inventory, and for analytics the permission named on each method
392
+ (`shipping:read`, `inventory:read`, `orders:read` or `returns:read`). A key without it gets 403
393
+ `API_KEY_SCOPE_REQUIRED`. Keys created with the default permissions have all of these.
394
+
379
395
  API keys, workspace settings and account management are managed in the FlexOps portals.
380
396
 
381
397
  ## Configuration
data/lib/flexops/http.rb CHANGED
@@ -61,8 +61,8 @@ module FlexOps
61
61
  request(:patch, path, body: body, headers: headers)
62
62
  end
63
63
 
64
- def delete(path, headers: {})
65
- request(:delete, path, headers: headers)
64
+ def delete(path, query: nil, headers: {})
65
+ request(:delete, path, query: query, headers: headers)
66
66
  end
67
67
 
68
68
  private
@@ -8,6 +8,12 @@
8
8
 
9
9
  module FlexOps
10
10
  module Resources
11
+ # Analytics through the legacy /api/ApiProxy passthrough (/api/ApiProxy/api/v4/Analytics/...).
12
+ # With an API key, each method requires the permission for the data it reads, named on the
13
+ # method: shipping:read for shipment, carrier and delivery metrics, inventory:read for
14
+ # inventory metrics, orders:read for order metrics and returns:read for returns metrics. A
15
+ # key without it gets 403 API_KEY_SCOPE_REQUIRED. Keys created with the default permissions
16
+ # have all four.
11
17
  class Analytics
12
18
  BASE = "/api/ApiProxy/api/v4/Analytics"
13
19
 
@@ -15,62 +21,77 @@ module FlexOps
15
21
  @http = http
16
22
  end
17
23
 
24
+ # Shipments trend over time. With an API key, requires shipping:read.
18
25
  def shipments_trend(start_date: nil, end_date: nil)
19
26
  @http.get("#{BASE}/ShipmentsTrend", query: date_query(start_date, end_date))
20
27
  end
21
28
 
29
+ # Carrier usage summary. With an API key, requires shipping:read.
22
30
  def carrier_summary(start_date: nil, end_date: nil)
23
31
  @http.get("#{BASE}/CarrierSummary", query: date_query(start_date, end_date))
24
32
  end
25
33
 
34
+ # Top shipping destinations. With an API key, requires shipping:read.
26
35
  def top_destinations(start_date: nil, end_date: nil, limit: nil)
27
36
  @http.get("#{BASE}/TopDestinations", query: date_query(start_date, end_date).merge(limit: limit).compact)
28
37
  end
29
38
 
39
+ # Inventory metrics (stock levels, low-stock alerts). With an API key, requires inventory:read.
30
40
  def inventory_metrics
31
41
  @http.get("#{BASE}/InventoryMetrics")
32
42
  end
33
43
 
44
+ # Stock levels by warehouse. With an API key, requires inventory:read.
34
45
  def stock_by_warehouse
35
46
  @http.get("#{BASE}/StockByWarehouse")
36
47
  end
37
48
 
49
+ # Order metrics (volume, revenue). With an API key, requires orders:read.
38
50
  def order_metrics(start_date: nil, end_date: nil)
39
51
  @http.get("#{BASE}/OrderMetrics", query: date_query(start_date, end_date))
40
52
  end
41
53
 
54
+ # Order trend over time. With an API key, requires orders:read.
42
55
  def order_trend(start_date: nil, end_date: nil)
43
56
  @http.get("#{BASE}/OrderTrend", query: date_query(start_date, end_date))
44
57
  end
45
58
 
59
+ # Top selling products. With an API key, requires orders:read.
46
60
  def top_selling_products(start_date: nil, end_date: nil, limit: nil)
47
61
  @http.get("#{BASE}/TopSellingProducts", query: date_query(start_date, end_date).merge(limit: limit).compact)
48
62
  end
49
63
 
64
+ # Returns metrics. With an API key, requires returns:read.
50
65
  def returns_metrics(start_date: nil, end_date: nil)
51
66
  @http.get("#{BASE}/ReturnsMetrics", query: date_query(start_date, end_date))
52
67
  end
53
68
 
69
+ # Returns trend over time. With an API key, requires returns:read.
54
70
  def returns_trend(start_date: nil, end_date: nil)
55
71
  @http.get("#{BASE}/ReturnsTrend", query: date_query(start_date, end_date))
56
72
  end
57
73
 
74
+ # Return reasons breakdown. With an API key, requires returns:read.
58
75
  def return_reasons(start_date: nil, end_date: nil)
59
76
  @http.get("#{BASE}/ReturnReasons", query: date_query(start_date, end_date))
60
77
  end
61
78
 
79
+ # Fulfillment performance metrics. With an API key, requires shipping:read.
62
80
  def performance_metrics(start_date: nil, end_date: nil)
63
81
  @http.get("#{BASE}/PerformanceMetrics", query: date_query(start_date, end_date))
64
82
  end
65
83
 
84
+ # Carrier delivery performance. With an API key, requires shipping:read.
66
85
  def carrier_performance(start_date: nil, end_date: nil)
67
86
  @http.get("#{BASE}/CarrierPerformance", query: date_query(start_date, end_date))
68
87
  end
69
88
 
89
+ # Shipping cost analytics. With an API key, requires shipping:read.
70
90
  def shipping_cost_analytics(start_date: nil, end_date: nil)
71
91
  @http.get("#{BASE}/ShippingCostAnalytics", query: date_query(start_date, end_date))
72
92
  end
73
93
 
94
+ # Delivery performance (on-time percentage). With an API key, requires shipping:read.
74
95
  def delivery_performance(start_date: nil, end_date: nil)
75
96
  @http.get("#{BASE}/DeliveryPerformance", query: date_query(start_date, end_date))
76
97
  end
@@ -9,7 +9,12 @@
9
9
  module FlexOps
10
10
  module Resources
11
11
  # Carrier-specific calls through the legacy /api/ApiProxy passthrough (sunset 2027-06-30).
12
- # Lookups (GET) work with an API key or a user token. The writes (POST/PUT/DELETE) are
12
+ # Lookups (GET) work with a user token, or an API key that has the permission for the data
13
+ # they read: address, ZIP, city, location, service point and reference lookups require
14
+ # shipping:read; tracking and proof of delivery require tracking:read; delivery standards,
15
+ # rates and products require rates:read. A key without the permission gets 403
16
+ # API_KEY_SCOPE_REQUIRED; keys created with the default permissions have all three.
17
+ # The writes (POST/PUT/DELETE) are
13
18
  # deprecated and need a user token (auth.login): the Gateway refuses API-key writes on the
14
19
  # proxy with 403 API_KEY_SCOPE_REQUIRED. Label generation through the proxy is closed (410 Gone);
15
20
  # buy, cancel and schedule through client.shipping, client.pickups and client.scan_forms.
@@ -25,6 +30,9 @@ module FlexOps
25
30
  end
26
31
  end
27
32
 
33
+ # USPS through the legacy proxy. With an API key, the address, ZIP, city and location lookups
34
+ # require shipping:read, tracking requires tracking:read and delivery standards require
35
+ # rates:read.
28
36
  class UspsCarrier
29
37
  PROXY = "/api/ApiProxy"
30
38
 
@@ -52,10 +60,12 @@ module FlexOps
52
60
  def get_international_prices(body) = @http.post("#{PROXY}/api/v3/RateCalculator/postUspsSearchEligibleInternationalPrices", body: body)
53
61
  # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
54
62
  # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
55
- def cancel_domestic_label = @http.delete("#{PROXY}/api/v3/Shipping/cancelUspsDomesticShipmentLabel")
63
+ def cancel_domestic_label(tracking_number) = cancel("/api/v3/Shipping/cancelUspsDomesticShipmentLabel", "trackingNumber", tracking_number)
56
64
  # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
57
65
  # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
58
- def cancel_international_label = @http.delete("#{PROXY}/api/v3/Shipping/cancelUspsInternationalShipmentLabel")
66
+ def cancel_international_label(international_tracking_number)
67
+ cancel("/api/v3/Shipping/cancelUspsInternationalShipmentLabel", "internationalTrackingNumber", international_tracking_number)
68
+ end
59
69
  def track_summary(params) = @http.get("#{PROXY}/api/v3/Tracking/getUspsTrackingSummaryInformation", query: params)
60
70
  def track_detail(params) = @http.get("#{PROXY}/api/v3/Tracking/getUspsTrackingDetailInformation", query: params)
61
71
  # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
@@ -63,15 +73,25 @@ module FlexOps
63
73
  def create_pickup(body) = @http.post("#{PROXY}/api/v3/CarrierPickup/postUspsCreateCarrierPickupSchedule", body: body)
64
74
  # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
65
75
  # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
66
- def cancel_pickup = @http.delete("#{PROXY}/api/v3/CarrierPickup/cancelUspsCarrierPickupSchedule")
76
+ def cancel_pickup(confirmation_number) = cancel("/api/v3/CarrierPickup/cancelUspsCarrierPickupSchedule", "confirmationNumber", confirmation_number)
67
77
  # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
68
78
  # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
69
79
  def create_scan_form(body) = @http.post("#{PROXY}/api/v3/ScanForm/postUspsCreateScanFormLabelShipment", body: body)
70
80
  def delivery_standards(params) = @http.get("#{PROXY}/api/v3/ServiceStandards/getUspsGetDeliveryStandardsEstimates", query: params)
71
81
  def find_drop_off_locations(params) = @http.get("#{PROXY}/api/v3/LocationSearch/getUspsFindValidDropOffLocations", query: params)
72
82
  def find_post_offices(params) = @http.get("#{PROXY}/api/v3/LocationSearch/getUspsFindValidPostOfficeLocations", query: params)
83
+
84
+ private
85
+
86
+ # Sends a USPS cancel with what it cancels in the query string; without it VSCS answers 400.
87
+ def cancel(path, param, value)
88
+ raise ArgumentError, "#{param} is required." if value.to_s.strip.empty?
89
+
90
+ @http.delete("#{PROXY}#{path}", query: { param => value })
91
+ end
73
92
  end
74
93
 
94
+ # UPS through the legacy proxy. With an API key, track requires tracking:read.
75
95
  class UpsCarrier
76
96
  PROXY = "/api/ApiProxy"
77
97
 
@@ -178,6 +198,9 @@ module FlexOps
178
198
  def confirm_open_shipment(body) = @http.post("#{PROXY}/api/v3/OpenShip/postFedExConfirmOpenShipment", body: body)
179
199
  end
180
200
 
201
+ # DHL through the legacy proxy. With an API key, address validation, service points and
202
+ # reference data require shipping:read; tracking and proof of delivery require
203
+ # tracking:read; rates and products require rates:read.
181
204
  class DhlCarrier
182
205
  PROXY = "/api/ApiProxy"
183
206
 
@@ -8,6 +8,10 @@
8
8
 
9
9
  module FlexOps
10
10
  module Resources
11
+ # Read-only inventory lookups through the legacy /api/ApiProxy passthrough
12
+ # (/api/ApiProxy/api/v1/Inventory/...). With an API key, every method requires the
13
+ # inventory:read permission; a key without it gets 403 API_KEY_SCOPE_REQUIRED. Keys created
14
+ # with the default permissions include inventory:read. The proxy refuses writes from API keys.
11
15
  class Inventory
12
16
  PROXY = "/api/ApiProxy"
13
17
 
@@ -8,6 +8,10 @@
8
8
 
9
9
  module FlexOps
10
10
  module Resources
11
+ # Read-only order lookups through the legacy /api/ApiProxy passthrough
12
+ # (/api/ApiProxy/api/v1/Order/...). With an API key, every method requires the orders:read
13
+ # permission; a key without it gets 403 API_KEY_SCOPE_REQUIRED. Keys created with the default
14
+ # permissions include orders:read. The proxy refuses writes from API keys.
11
15
  class Orders
12
16
  BASE = "/api/ApiProxy/api/v1/Order"
13
17
 
@@ -16,6 +16,8 @@ module FlexOps
16
16
  @ws_id = ws_id_proc
17
17
  end
18
18
 
19
+ # Schedules a carrier pickup. API keys need shipping:write; the public demo keys are refused.
20
+ #
19
21
  # request:: { carrierCode:, pickupDate:, street:, city:, state:, postalCode:,
20
22
  # country: (optional, "US"), packageCount:, totalWeightOz: (optional),
21
23
  # specialInstructions:, readyTime:, closeTime:, contactName:, contactPhone: (all optional) }
@@ -33,6 +35,7 @@ module FlexOps
33
35
  @http.get("#{ws_path}/pickups/#{pickup_id}", query: carrier_code.nil? ? nil : { carrierCode: carrier_code })
34
36
  end
35
37
 
38
+ # Cancels a scheduled pickup. API keys need shipping:write; the public demo keys are refused.
36
39
  # The Gateway uses "usps" when carrier_code is left out.
37
40
  def cancel(pickup_id, carrier_code: nil)
38
41
  query = carrier_code.nil? ? "" : "?#{URI.encode_www_form(carrierCode: carrier_code)}"
@@ -57,7 +57,9 @@ module FlexOps
57
57
  # missing fields.
58
58
  #
59
59
  # Only what you set is sent; the Gateway uses "usps" when carrier_code is left out.
60
- # hazmat_classification (a HazmatClassification) declares what the return holds.
60
+ # hazmat_classification (a HazmatClassification) declares what the return holds. Return labels
61
+ # cannot carry HAZMAT yet: send HazmatClassification::NONE or leave it out (any other class is
62
+ # refused with 400 HazmatNotSupported).
61
63
  def generate_label(rma_number, weight_oz: nil, length_in: nil, width_in: nil, height_in: nil,
62
64
  carrier_code: nil, service_type: nil, payment_confirmed: nil, hazmat_classification: nil,
63
65
  rate_indicator: nil)
@@ -24,21 +24,25 @@ module FlexOps
24
24
 
25
25
  # rule:: { name:, priority:, isEnabled:, warehouseId: (optional),
26
26
  # conditions: [{ field:, operator:, value:, group: (optional) }], actions: [{ type:, value: }] }
27
+ # Signed-in users only: API keys get 403 SIGNED_IN_USER_REQUIRED, whatever their scopes.
27
28
  def create(rule)
28
29
  @http.post("#{ws_path}/shipping-rules", body: rule)
29
30
  end
30
31
 
31
32
  # rule:: the fields to change, named as for #create.
33
+ # Signed-in users only: API keys get 403 SIGNED_IN_USER_REQUIRED, whatever their scopes.
32
34
  def update(rule_id, rule)
33
35
  @http.put("#{ws_path}/shipping-rules/#{rule_id}", body: rule)
34
36
  end
35
37
 
38
+ # Signed-in users only: API keys get 403 SIGNED_IN_USER_REQUIRED, whatever their scopes.
36
39
  def delete(rule_id)
37
40
  @http.delete("#{ws_path}/shipping-rules/#{rule_id}")
38
41
  end
39
42
 
40
43
  # Sets rule priorities. +rules+ is an array of { ruleId:, priority: } hashes, or of rule ids
41
44
  # in the order to apply (the first gets priority 0).
45
+ # Signed-in users only: API keys get 403 SIGNED_IN_USER_REQUIRED, whatever their scopes.
42
46
  def reorder(rules)
43
47
  items = rules.each_with_index.map do |rule, index|
44
48
  rule.is_a?(Hash) ? rule : { ruleId: rule, priority: index }
@@ -47,6 +51,7 @@ module FlexOps
47
51
  end
48
52
 
49
53
  # Evaluates the workspace's rules against a sample shipment without changing anything.
54
+ # API keys need shipping:read; the public demo keys are refused.
50
55
  #
51
56
  # request:: { weightOz:, declaredValue:, destinationCountry:, destinationState:,
52
57
  # destinationPostalCode:, orderSource:, itemCount:, shipMethod:, destinationZone:, tags: }
@@ -6,22 +6,66 @@
6
6
  # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
7
  # ***********************************************************************
8
8
 
9
+ require "securerandom"
10
+
9
11
  module FlexOps
10
12
  module Resources
13
+ # USPS SCAN forms. Replies are the Gateway envelope as a Hash with string keys:
14
+ # { "success" => true, "message" => nil, "data" => form, "errors" => nil, "errorCode" => nil }.
15
+ # A form (the Gateway's ScanFormDto) has:
16
+ #
17
+ # "scanFormId":: 32 hexadecimal digits, no dashes; pass it to #get.
18
+ # "state":: FlexOps::ScanFormState::CREATED or OUTCOME_UNKNOWN.
19
+ # "originPostalCode":: the origin ZIP, or nil.
20
+ # "trackingNumbers":: the packages on the form.
21
+ # "createdAtUtc":: ISO 8601 in UTC, for example "2026-09-08T21:14:03.512Z".
22
+ # "scanFormImage":: the PS Form 5630 PDF, base64; nil for sandbox forms and unresolved
23
+ # operations. FlexOps::ScanFormState.pdf_bytes decodes it.
24
+ # "manifestNumber":: the USPS manifest number; "SBOX..." in the sandbox, "" while unresolved.
25
+ # "manifestDate":: the mailing date, for example "2026-09-08T00:00:00".
26
+ # "packageCount":: how many packages the form covers.
27
+ # "statusMessage":: a status line for people.
28
+ # "success":: true when "state" is "Created".
29
+ # "errorMessage":: not set by the current Gateway; refusals raise FlexOps::Error.
30
+ # "isSandbox":: true for a sandbox (test key) form, never sent to USPS.
11
31
  class ScanForms
12
32
  def initialize(http, ws_id_proc)
13
33
  @http = http
14
34
  @ws_id = ws_id_proc
15
35
  end
16
36
 
37
+ # Creates a USPS SCAN form. API keys need shipping:write; the public demo keys are refused.
38
+ #
39
+ # request:: { trackingNumbers: [...] (1 to 1,000), manifestDate: "YYYY-MM-DD",
40
+ # requestId: (optional), originPostalCode: (optional), warehouseId: (optional) }
41
+ #
42
+ # Live creation needs manifestDate: without it the Gateway answers 400 "Choose the label
43
+ # mailing date." The sandbox defaults it to today (UTC).
44
+ #
45
+ # requestId identifies the operation. Repeating a request with the same ID returns the same
46
+ # form instead of creating another; reusing it with different inputs is 409. When it is left
47
+ # out, one is generated for this call and sent on every retry of it, so a retry cannot create
48
+ # a second form. Pass your own to retry the call yourself after an unknown outcome.
49
+ #
50
+ # Returns the envelope whose "data" is the created form, also when the same requestId and
51
+ # inputs are sent again. Raises FlexOps::Error with status 409 when the requestId was used
52
+ # with different inputs, when the packages already belong to a manifest, or when this or an
53
+ # earlier operation needs review (its message names the operation; do not resubmit those
54
+ # packages), and 400 when the request or package selection is refused. A 409 is not retried.
17
55
  def create(request)
18
- @http.post("#{ws_path}/scan-forms", body: request)
56
+ given = request[:requestId] || request["requestId"]
57
+ body = given ? request : request.reject { |key, _| key.to_s == "requestId" }.merge(requestId: SecureRandom.uuid)
58
+ @http.post("#{ws_path}/scan-forms", body: body)
19
59
  end
20
60
 
61
+ # Lists the workspace's saved scan forms ("data" is an Array of forms), newest mailing date
62
+ # first, including any whose outcome is unknown.
21
63
  def list
22
64
  @http.get("#{ws_path}/scan-forms")
23
65
  end
24
66
 
67
+ # Gets a saved scan form by its "scanFormId". A form that does not exist raises
68
+ # FlexOps::Error with status 404.
25
69
  def get(scan_form_id)
26
70
  @http.get("#{ws_path}/scan-forms/#{scan_form_id}")
27
71
  end
@@ -92,10 +92,16 @@ module FlexOps
92
92
  end
93
93
 
94
94
  # The payload is sent as given. Set hazmatClassification on an item only when you have a declaration.
95
+ # Batch labels cannot carry HAZMAT yet: send HazmatClassification::NONE or leave hazmatClassification
96
+ # out (any other class is refused with 400 HazmatNotSupported). Buy HAZMAT labels one at a time
97
+ # with create_label.
95
98
  def create_batch(request)
96
99
  @http.post("#{ws_path}/labels/batch", body: request)
97
100
  end
98
101
 
102
+ # Previews a batch without buying labels. The payload is sent as given; as with create_batch, an
103
+ # item's hazmatClassification accepts only HazmatClassification::NONE or nothing (any other class
104
+ # is refused with 400 HazmatNotSupported).
99
105
  def preview_batch(request)
100
106
  @http.post("#{ws_path}/labels/batch/preview", body: request)
101
107
  end
@@ -10,12 +10,29 @@ require "openssl"
10
10
 
11
11
  module FlexOps
12
12
  module Resources
13
+ # Webhook subscription management and signature verification.
14
+ #
15
+ # Every method that calls the Gateway needs a signed-in user's access token
16
+ # (Client.new(access_token: ...) or Client#set_access_token with the token from
17
+ # auth.login). The Gateway refuses API keys on every webhook route, reads included,
18
+ # with 403 SIGNED_IN_USER_REQUIRED, whatever the key's scopes; the SDK raises
19
+ # FlexOps::Error with that +code+. Reads are refused too because webhook delivery
20
+ # logs carry each delivery's payload (order and customer data). A client with an API
21
+ # key sends the key even when an access token is also set, so call these methods
22
+ # from a client created without +api_key+. verify_signature and verify_signature!
23
+ # make no request and need no credentials.
13
24
  class Webhooks
14
25
  def initialize(http, ws_id_proc)
15
26
  @http = http
16
27
  @ws_id = ws_id_proc
17
28
  end
18
29
 
30
+ # Lists the workspace's webhook subscriptions.
31
+ #
32
+ # Needs a signed-in user's access token. With an API key the Gateway answers 403
33
+ # and this raises FlexOps::Error with code "SIGNED_IN_USER_REQUIRED". Reads are
34
+ # signed-in only, like the writes, because webhook delivery logs carry each
35
+ # delivery's payload (order and customer data).
19
36
  def list
20
37
  @http.get("#{ws_path}/webhooks")
21
38
  end
@@ -35,6 +52,9 @@ module FlexOps
35
52
  # "secret", ...), not a success/data envelope: the Gateway answers 201 with the
36
53
  # bare subscription. An enveloped answer is unwrapped, so "secret" (the whsec_...
37
54
  # signing secret, returned only here) is always at the top level.
55
+ #
56
+ # Needs a signed-in user's access token. With an API key the Gateway answers 403
57
+ # and this raises FlexOps::Error with code "SIGNED_IN_USER_REQUIRED".
38
58
  def create(request)
39
59
  unwrap_subscription(@http.post("#{ws_path}/webhooks", body: to_wire_request(request)))
40
60
  end
@@ -42,23 +62,43 @@ module FlexOps
42
62
  # Updates a webhook subscription. +data+ takes +url+, +description+, +status+
43
63
  # ("Active", "Paused" or "Disabled") and +events+, which replaces the subscribed
44
64
  # events and is sent as +eventTypes+. Raises ArgumentError as #create does.
65
+ #
66
+ # Needs a signed-in user's access token. With an API key the Gateway answers 403
67
+ # and this raises FlexOps::Error with code "SIGNED_IN_USER_REQUIRED".
45
68
  def update(webhook_id, data)
46
69
  @http.put("#{ws_path}/webhooks/#{webhook_id}", body: to_wire_request(data))
47
70
  end
48
71
 
72
+ # Deletes a webhook subscription.
73
+ #
74
+ # Needs a signed-in user's access token. With an API key the Gateway answers 403
75
+ # and this raises FlexOps::Error with code "SIGNED_IN_USER_REQUIRED".
49
76
  def delete(webhook_id)
50
77
  @http.delete("#{ws_path}/webhooks/#{webhook_id}")
51
78
  end
52
79
 
80
+ # Rotates the subscription's signing secret.
81
+ #
82
+ # Needs a signed-in user's access token. With an API key the Gateway answers 403
83
+ # and this raises FlexOps::Error with code "SIGNED_IN_USER_REQUIRED".
53
84
  def rotate_secret(webhook_id)
54
85
  @http.post("#{ws_path}/webhooks/#{webhook_id}/rotate-secret")
55
86
  end
56
87
 
88
+ # Lists the subscription's delivery logs.
89
+ #
90
+ # Needs a signed-in user's access token. With an API key the Gateway answers 403
91
+ # and this raises FlexOps::Error with code "SIGNED_IN_USER_REQUIRED". Reads are
92
+ # signed-in only, like the writes, because webhook delivery logs carry each
93
+ # delivery's payload (order and customer data).
57
94
  def list_delivery_logs(webhook_id)
58
95
  @http.get("#{ws_path}/webhooks/#{webhook_id}/deliveries")
59
96
  end
60
97
 
61
98
  # Sends a test event (X-FlexOps-Event "test") to the subscription's URL.
99
+ #
100
+ # Needs a signed-in user's access token. With an API key the Gateway answers 403
101
+ # and this raises FlexOps::Error with code "SIGNED_IN_USER_REQUIRED".
62
102
  def test(webhook_id)
63
103
  @http.post("#{ws_path}/webhooks/#{webhook_id}/test")
64
104
  end
@@ -29,16 +29,19 @@ module FlexOps
29
29
 
30
30
  # Invites a member. +role+ is a FlexOps::WorkspaceRole number or name (for example
31
31
  # :member); it is required because the Gateway reads a missing role as Owner.
32
+ # Signed-in users only: API keys get 403 SIGNED_IN_USER_REQUIRED, whatever their scopes.
32
33
  def invite_member(email:, role:)
33
34
  @http.post("/api/workspaces/#{@ws_id.call}/members/invite",
34
35
  body: { email: email, role: FlexOps::WorkspaceRole.to_wire(role) })
35
36
  end
36
37
 
38
+ # Signed-in users only: API keys get 403 SIGNED_IN_USER_REQUIRED, whatever their scopes.
37
39
  def remove_member(user_id)
38
40
  @http.delete("/api/workspaces/#{@ws_id.call}/members/#{user_id}")
39
41
  end
40
42
 
41
43
  # Changes a member's role; +role+ is a FlexOps::WorkspaceRole number or name.
44
+ # Signed-in users only: API keys get 403 SIGNED_IN_USER_REQUIRED, whatever their scopes.
42
45
  def update_member_role(user_id, role)
43
46
  @http.put("/api/workspaces/#{@ws_id.call}/members/#{user_id}/role",
44
47
  body: { newRole: FlexOps::WorkspaceRole.to_wire(role) })
@@ -0,0 +1,31 @@
1
+ # ***********************************************************************
2
+ # Package : flexops
3
+ # Author : FlexOps, LLC
4
+ # Created : 2026-10-24
5
+ #
6
+ # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
+ # ***********************************************************************
8
+
9
+ module FlexOps
10
+ # The "state" of a SCAN form hash returned by client.scan_forms (the Gateway's ScanFormDto).
11
+ # The Gateway may add other values; treat them like OUTCOME_UNKNOWN.
12
+ module ScanFormState
13
+ # USPS returned the manifest, and the Gateway saved its number and PDF.
14
+ CREATED = "Created"
15
+ # The carrier's answer was lost or did not match. The packages stay reserved: do not submit
16
+ # them again until the outcome is resolved.
17
+ OUTCOME_UNKNOWN = "OutcomeUnknown"
18
+
19
+ # True when a SCAN form hash ("data" of create/get, or an item of list) is a created form.
20
+ def self.created?(form)
21
+ form.is_a?(Hash) && form["state"] == CREATED
22
+ end
23
+
24
+ # Decodes a SCAN form hash's "scanFormImage" (the base64 PS Form 5630 PDF), or returns nil when
25
+ # it has none: sandbox forms are never sent to USPS, and an unresolved operation has no PDF.
26
+ def self.pdf_bytes(form)
27
+ image = form.is_a?(Hash) ? form["scanFormImage"] : nil
28
+ image.nil? ? nil : image.unpack1("m0")
29
+ end
30
+ end
31
+ end
@@ -7,5 +7,5 @@
7
7
  # ***********************************************************************
8
8
 
9
9
  module FlexOps
10
- VERSION = "2.2.0"
10
+ VERSION = "2.3.0"
11
11
  end
data/lib/flexops.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "flexops/errors"
11
11
  require_relative "flexops/hazmat_classification"
12
12
  require_relative "flexops/webhook_event_type"
13
13
  require_relative "flexops/workspace_role"
14
+ require_relative "flexops/scan_form_state"
14
15
  require_relative "flexops/http"
15
16
  require_relative "flexops/client"
16
17
  require_relative "flexops/resources/auth"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flexops
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - FlexOps, LLC
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-23 00:00:00.000000000 Z
11
+ date: 2026-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -87,6 +87,7 @@ files:
87
87
  - lib/flexops/resources/wallet.rb
88
88
  - lib/flexops/resources/webhooks.rb
89
89
  - lib/flexops/resources/workspaces.rb
90
+ - lib/flexops/scan_form_state.rb
90
91
  - lib/flexops/version.rb
91
92
  - lib/flexops/webhook_event_type.rb
92
93
  - lib/flexops/workspace_role.rb