""" Emergency "Send to ShipStation" - for the rare case a ticket needs to skip the normal daily batch and get to ShipStation right away. Two independent paths, on purpose (per "in case the API goes down we still have an option for CSV uploads"): - export_order_to_shipstation_csv(): writes rows matching your real upload template exactly (columns confirmed against OAK.csv) - one row per line item, customer/address info repeated on each row. - send_order_to_shipstation_api(): calls ShipStation's V2 API directly (POST /v2/shipments with create_sales_order: true) to create the order without leaving the app. Needs a Warehouse ID per company (SHIPSTATION_SIGNIFY_WAREHOUSE_ID / SHIPSTATION_OAKSTREET_WAREHOUSE_ID) - ShipStation requires knowing where the package ships FROM, either via a warehouse or an explicit ship_from address; only the warehouse path is wired up today. IMPORTANT - please verify the first real send: ShipStation's docs say automation rules apply tags to orders "when they import based on any criteria you set" - meaning your 90 box-packing rules should fire automatically off the SKU/item data here, same as your CSV import, with no manual tagging needed. That's the best read of the docs, but it's not something I can verify without your actual account, so treat the first live send (API or CSV) as a test: confirm ShipStation packs and prices it the way a normal order would before trusting it in a real emergency. """ from __future__ import annotations import csv import datetime as dt from pathlib import Path from typing import List import requests from app import config from app.models import Order API_BASE = "https://api.shipstation.com/v2" REQUEST_TIMEOUT_SECONDS = 30 def list_carriers() -> List[dict]: """ Calls GET /v2/carriers with whichever API key is currently active (test or production, via config.get_shipstation_setting) - a direct way to answer "what carrier_id is actually valid here" instead of guessing or hunting through ShipStation's UI while unsure which account is even logged in. Each carrier includes carrier_id, carrier_code, friendly_name, and nickname. """ api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY") if not api_key: raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.") try: response = requests.get( f"{API_BASE}/carriers", headers={"API-Key": api_key, "Accept": "application/json"}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc if response.status_code == 401: raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.") if not response.ok: raise ShipStationSendError( f"ShipStation returned an error ({response.status_code}): {response.text[:400]}" ) try: data = response.json() except ValueError as exc: raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc return data.get("carriers", []) def list_stores() -> List[dict]: """ DEAD END, kept only so nobody re-attempts this the hard way: ShipStation's V2 API has NO stores/marketplaces listing endpoint. GET /v2/stores returns a plain 404 ("No route matched with those values") - confirmed against ShipStation's own V2 OpenAPI reference, which lists every real section (Carriers, Warehouses, Connections, etc.) and where store_id only ever appears as an INPUT field on label/shipment requests (example value "se-12345"), never as its own resource with a list/get endpoint. This matches ShipStation's help docs too, which say the only ways to get a store_id are (1) ShipStation support looks it up for you, or (2) the legacy V1 API's List Stores call - different auth (key+secret Basic Auth), not the single V2 API-Key header this app uses everywhere else. Bottom line: there is no way to discover a store_id from inside this app. Log into the ShipStation account's own UI (Settings > Store Setup / Selling Channels) to find or create a manual store and read its ID from there - for test mode specifically, that means logging into the TEST/sandbox account, not production. """ raise ShipStationSendError( "ShipStation's V2 API has no endpoint to list stores (confirmed - GET /v2/stores " "doesn't exist, it 404s). Log into the ShipStation account's own UI under " "Settings > Store Setup to find the store_id, then enter it in Settings - for " "test mode, log into the TEST/sandbox account and set the TEST_ override." ) def list_warehouses() -> List[dict]: """Same idea as list_carriers()/list_stores() but for GET /v2/warehouses - fixes the exact next error in the sequence (warehouse_id not found), same root cause as the carrier_id one: the test/sandbox account is a completely separate ShipStation environment with its own IDs.""" api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY") if not api_key: raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.") try: response = requests.get( f"{API_BASE}/warehouses", headers={"API-Key": api_key, "Accept": "application/json"}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc if response.status_code == 401: raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.") if not response.ok: raise ShipStationSendError( f"ShipStation returned an error ({response.status_code}): {response.text[:400]}" ) try: data = response.json() except ValueError as exc: raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc return data if isinstance(data, list) else data.get("warehouses", []) def create_warehouse(name: str, origin_address: dict) -> dict: """POST /v2/warehouses - creates a new warehouse for whichever API key is currently active. Needed because a sandbox/test ShipStation account starts with zero warehouses (confirmed against ShipStation's own docs): list_warehouses() correctly returning an empty list in test mode wasn't a bug, there was just nothing there yet to list. Returns the created warehouse's JSON (includes warehouse_id - the value that goes into TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID / TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID).""" api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY") if not api_key: raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.") payload = {"name": name, "origin_address": origin_address} try: response = requests.post( f"{API_BASE}/warehouses", json=payload, headers={"API-Key": api_key, "Accept": "application/json"}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc if response.status_code == 401: raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.") if not response.ok: raise ShipStationSendError( f"ShipStation returned an error ({response.status_code}): {response.text[:400]}" ) try: return response.json() except ValueError as exc: raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc def _origin_address_for_company(company: str) -> dict: """Same physical address as _return_address_for_order's ship-from/ ship-to address (SIGNIFY_RETURN_*/OAKSTREET_RETURN_* settings) - a warehouse's origin_address is just that address registered with ShipStation. address_residential_indicator is required by POST /v2/warehouses; this is always a business address.""" settings = config.load_settings() prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_" return { "name": settings.get(f"{prefix}NAME", ""), "phone": settings.get(f"{prefix}PHONE", ""), "company_name": company, "address_line1": settings.get(f"{prefix}ADDRESS1", ""), "address_line2": settings.get(f"{prefix}ADDRESS2", "") or None, "city_locality": settings.get(f"{prefix}CITY", ""), "state_province": settings.get(f"{prefix}STATE", ""), "postal_code": settings.get(f"{prefix}ZIP", ""), "country_code": "US", "address_residential_indicator": "no", } def create_test_warehouse_for_company(company: str) -> dict: """Creates a warehouse for whichever API key is currently active (callers should confirm test mode is on first - this isn't something you want to accidentally run against production) using the company's own return-address settings as the origin_address. Returns the created warehouse's JSON.""" origin_address = _origin_address_for_company(company) required_fields = ( "name", "phone", "address_line1", "city_locality", "state_province", "postal_code", ) missing = [field for field in required_fields if not origin_address.get(field)] if missing: prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_" raise ShipStationSendError( f"{prefix}* address settings for {company} are incomplete (missing: " f"{', '.join(missing)}). Fill those in under Settings > Return Labels first." ) return create_warehouse(f"{company} (Test)", origin_address) # Column order confirmed against the real ShipStation upload template # (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and # is intentionally left out here. CSV_COLUMNS = [ "Custom field (Email Address)", "Summary", "Custom field (Name)", "Custom field (NPI Number)", "Custom field (Address 1)", "Custom field (Address 2)", "Custom field (City)", "Custom field (State)", "Custom field (Zip Code)", "Issue key", "Issue id", "Status", "deliverables", "SKU", "Item Name", "Quantity", ] class ShipStationSendError(Exception): """Raised for any emergency-send failure, with a message safe to show in the UI.""" def _deliverable_text(sku: str, item_name: str) -> str: return f"{sku}: {item_name}" if item_name else sku def build_csv_rows(order: Order) -> List[List[str]]: """One row per line item, matching the real template column-for-column. Falls back to a single row (blank SKU/Item Name) if there are no line items yet, so the customer/address info is still exportable.""" info = order.shipping_info or {} line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])] if not line_items: line_items = [{"sku": "", "item_name": ""}] issue_id = "" if isinstance(order.raw_data, dict): issue_id = order.raw_data.get("id", "") rows = [] for item in line_items: rows.append( [ info.get("email", ""), order.summary, info.get("name", ""), info.get("npi", ""), info.get("address1", ""), info.get("address2", ""), info.get("city", ""), info.get("state", ""), info.get("zip", ""), order.ticket_number or order.external_id, issue_id, order.status, _deliverable_text(item.get("sku", ""), item.get("item_name", "")), item.get("sku", ""), item.get("item_name", ""), 1, # quantity - not tracked per-item today, defaults to 1 per line ] ) return rows def export_order_to_shipstation_csv(orders: List[Order], filepath: str) -> int: """Write one or more orders to a CSV in the real upload template shape. Returns the number of line-item rows written (not the number of orders, since one order can produce several rows).""" path = Path(filepath) path.parent.mkdir(parents=True, exist_ok=True) row_count = 0 with path.open("w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(CSV_COLUMNS) for order in orders: for row in build_csv_rows(order): writer.writerow(row) row_count += 1 return row_count def _normalize_shipstation_id(value: str) -> str: """ Every ShipStation reference ID we've seen (store, warehouse, carrier) consistently uses an "se-" prefix (se-599657, se-367672, etc). Prepends it if it's missing - a bare numeric ID would always fail with a confusing "not found" error otherwise, and it's an easy typo to make when copying a value out of ShipStation's own UI or filling in a new setting (this happened for real: a test-mode carrier ID was entered as "599657" instead of "se-599657"). """ value = (value or "").strip() if value and not value.startswith("se-") and value.replace("-", "").isalnum(): return f"se-{value}" return value def _store_id_for_company(company: str) -> str: if company == "Signify Health": return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_SIGNIFY_STORE_ID")) if company == "Oak Street Health": return _normalize_shipstation_id(config.get_shipstation_setting("SHIPSTATION_OAKSTREET_STORE_ID")) return "" def _warehouse_id_for_company(company: str) -> str: if company == "Signify Health": return _normalize_shipstation_id( config.get_shipstation_setting("SHIPSTATION_SIGNIFY_WAREHOUSE_ID") ) if company == "Oak Street Health": return _normalize_shipstation_id( config.get_shipstation_setting("SHIPSTATION_OAKSTREET_WAREHOUSE_ID") ) return "" def send_order_to_shipstation_api(order: Order) -> dict: """ Creates the order directly in ShipStation via POST /v2/shipments with create_sales_order: true, so it lands in the Orders tab and (per ShipStation's own docs on import automation) should pick up your box-packing rules the same as a CSV-imported order. Returns the created shipment's JSON on success. """ settings = config.load_settings() api_key = settings.get("SHIPSTATION_API_KEY", "") if not api_key: raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.") store_id = _store_id_for_company(order.company) if not store_id: raise ShipStationSendError( f"No ShipStation Store ID configured for '{order.company}'. " "Add it in Settings under ShipStation." ) # ShipStation needs to know where the package ships FROM - either a # configured warehouse, or an explicit ship_from address. Only the # warehouse path is wired up today; fail clearly here rather than # sending an incomplete request and getting ShipStation's less # actionable "ship_from is required when warehouse_id is not present". warehouse_id = _warehouse_id_for_company(order.company) if not warehouse_id: raise ShipStationSendError( f"No ShipStation Warehouse ID configured for '{order.company}'. " "Add it in Settings under ShipStation - find it in ShipStation under " "Settings > Shipping > Warehouses/Ship From Locations." ) info = order.shipping_info or {} if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")): raise ShipStationSendError( "This ticket is missing address information (address/city/state/zip) - " "can't create a shipment without a destination." ) line_items = order.line_items or [{"sku": s, "item_name": ""} for s in (order.skus or [])] if not line_items: raise ShipStationSendError("This ticket has no SKUs/line items to send.") ticket_number = order.ticket_number or order.external_id payload = { "shipments": [ { "create_sales_order": True, "store_id": store_id, "warehouse_id": warehouse_id, "external_shipment_id": ticket_number, "shipment_number": ticket_number, "ship_to": { "name": info.get("name", ""), "phone": info.get("phone", ""), "company_name": order.company, "address_line1": info.get("address1", ""), "address_line2": info.get("address2", "") or None, "city_locality": info.get("city", ""), "state_province": info.get("state", ""), "postal_code": info.get("zip", ""), "country_code": "US", }, "items": [ { "sku": item.get("sku", ""), "name": item.get("item_name") or item.get("sku", ""), "quantity": 1, } for item in line_items ], } ] } try: response = requests.post( f"{API_BASE}/shipments", json=payload, headers={"API-Key": api_key, "Accept": "application/json"}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc if response.status_code == 401: raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.") if not response.ok: raise ShipStationSendError( f"ShipStation returned an error ({response.status_code}): {response.text[:400]}" ) try: data = response.json() except ValueError as exc: raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc shipments = data.get("shipments", []) if not shipments: raise ShipStationSendError(f"ShipStation didn't return a created shipment: {data}") result = shipments[0] if result.get("errors"): raise ShipStationSendError(f"ShipStation reported errors: {result['errors']}") return result # --- Emailed return labels (SH007 / OK012) -------------------------------- # # A genuinely different shape from the outbound send above: for a return # label, ship_from is the CUSTOMER and ship_to is YOUR warehouse/return # center (confirmed against ShipStation's own return-labels docs - this is # reversed from every other label this app creates). This also doesn't go # through create_sales_order + automation, since there's no outbound side # to it - carrier/service are specified directly. # # After creation, this app does NOT email the label - per your workflow, # that's done from ShipStation itself (Returns tab -> Other Actions -> # Send Return Label) so it goes out through your branded email template. VALID_CHARGE_EVENTS = {"on_creation", "on_carrier_acceptance", "carrier_default"} def _is_rubiconmd_order(order: Order) -> bool: return any((sku or "").strip().upper().startswith("RMD") for sku in (order.skus or [])) def _return_address_for_order(order: Order) -> dict: """RubiconMD (RMD-prefixed SKUs) uses Oak Street's own address in every respect except the name on the label - not a separate warehouse, just a different name for the same shipping account/address.""" settings = config.load_settings() prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_" name = settings.get(f"{prefix}NAME", "") if _is_rubiconmd_order(order): name = settings.get("RUBICONMD_RETURN_NAME", "") or name return { "name": name, "phone": settings.get(f"{prefix}PHONE", ""), "address_line1": settings.get(f"{prefix}ADDRESS1", ""), "address_line2": settings.get(f"{prefix}ADDRESS2", "") or None, "city_locality": settings.get(f"{prefix}CITY", ""), "state_province": settings.get(f"{prefix}STATE", ""), "postal_code": settings.get(f"{prefix}ZIP", ""), "country_code": "US", } def _return_carrier_for_order(order: Order) -> tuple[str, str]: """RubiconMD uses Oak Street's own UPS account - no separate carrier, just a different name on the return address (see _return_address_for_order). Each of the two REAL shipping accounts (Signify, Oak Street) has its own carrier_id even though they share a physical warehouse.""" prefix = "SIGNIFY_RETURN_" if order.company == "Signify Health" else "OAKSTREET_RETURN_" return ( _normalize_shipstation_id(config.get_shipstation_setting(f"{prefix}CARRIER_ID")), config.get_shipstation_setting(f"{prefix}SERVICE_CODE"), ) def _build_return_package(weight_oz: float, length: float, width: float, height: float) -> dict: package = {"weight": {"value": weight_oz, "unit": "ounce"}} if length > 0 and width > 0 and height > 0: package["dimensions"] = { "length": length, "width": width, "height": height, "unit": "inch", } return package def _post_to_labels(payload: dict, api_key: str) -> dict: """Shared POST /v2/labels caller for both the dummy outbound and the actual return label - same endpoint, same response shape, same status-field pitfall (a 200 can still carry status: "error" or "voided" alongside a label_id, which looks like success unless you check status specifically).""" try: response = requests.post( f"{API_BASE}/labels", json=payload, headers={"API-Key": api_key, "Accept": "application/json"}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc if response.status_code == 401: raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.") if not response.ok: raise ShipStationSendError( f"ShipStation returned an error ({response.status_code}): {response.text[:400]}" ) try: data = response.json() except ValueError as exc: raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc if data.get("errors"): raise ShipStationSendError(f"ShipStation reported errors: {data['errors']}") label_status = data.get("status") if label_status == "error": raise ShipStationSendError( f"ShipStation created label {data.get('label_id', '?')} but its status is " f"'error' - it was not actually completed. Full response: {data}" ) if label_status == "voided": raise ShipStationSendError( f"ShipStation reports label {data.get('label_id', '?')} as already voided." ) return data def _void_label(label_id: str, api_key: str) -> None: """Best-effort cleanup - if the real return label fails to create after the dummy outbound succeeded, void the dummy rather than leave a paid, unused label sitting in the account. Deliberately swallows its own errors: this runs during an already-failing operation, and a secondary failure here shouldn't mask the original error or crash the app - worst case, an unused dummy label needs manual voiding.""" try: requests.put( f"{API_BASE}/labels/{label_id}/void", headers={"API-Key": api_key, "Accept": "application/json"}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException: pass def _create_dummy_outbound_label( order: Order, api_key: str, carrier_id: str, service_code: str, store_id: str, external_shipment_id: str, ) -> dict: """ Mirrors what your team already does by hand in ShipStation's GUI: create a minimal, cheap outbound label (1x1x1in, 1oz) purely so a real return label can be linked to it via outbound_label_id - which is apparently what actually makes a return label findable/visible in your account, confirmed against your own working manual process rather than assumed from the API docs alone. Same shipping account as the return itself; ship_from/ship_to are the reverse of the return (this one goes warehouse -> customer, matching a normal outbound). Returns the dummy's label_id. external_shipment_id is passed in (rather than computed here) since it must be unique per account - the caller uses a suffixed variant for this dummy, distinct from the real return label's. ShipStation's own docs confirm this field exists on both shipments and labels specifically to correlate a record back to your own system, and it's what populates the "Order #" column - this was missing entirely before, on both this dummy and the real return label. warehouse_id is used INSTEAD of ship_from when available (confirmed by a real ShipStation error: "ship_from and warehouse_id cannot be provided in same request" - they're mutually exclusive, not additive). When a warehouse_id is configured, ShipStation resolves the ship_from address from the registered warehouse record itself. This only affects the throwaway dummy - it's never seen by anyone, so it doesn't matter that this bypasses the RubiconMD name-override logic in _return_address_for_order; the real return label (which people do see) still uses that function directly. """ warehouse_id = _warehouse_id_for_company(order.company) info = order.shipping_info or {} shipment: dict = { "carrier_id": carrier_id, "service_code": service_code, "store_id": store_id, "ship_to": { "name": info.get("name", ""), "phone": info.get("phone", ""), "address_line1": info.get("address1", ""), "address_line2": info.get("address2", "") or None, "city_locality": info.get("city", ""), "state_province": info.get("state", ""), "postal_code": info.get("zip", ""), "country_code": "US", }, "packages": [_build_return_package(1.0, 1.0, 1.0, 1.0)], } if warehouse_id: shipment["warehouse_id"] = warehouse_id else: shipment["ship_from"] = _return_address_for_order(order) payload = { "external_shipment_id": external_shipment_id, "shipment": shipment, } return _post_to_labels(payload, api_key) def _validate_return_label_prerequisites(order: Order) -> tuple[str, str, str, str]: """Shared validation for both steps - returns (api_key, carrier_id, service_code, store_id) or raises with a clear, specific message.""" api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY") if not api_key: raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.") carrier_id, service_code = _return_carrier_for_order(order) if not carrier_id or not service_code: raise ShipStationSendError( f"No return-label Carrier ID/Service Code configured for '{order.company}'. " "Add them in Settings under Return Labels." ) store_id = _store_id_for_company(order.company) if not store_id: raise ShipStationSendError( f"No ShipStation Store ID configured for '{order.company}'. Add it in Settings " "under ShipStation. A label created without one may not show up anywhere in " "ShipStation's UI even though the API reports success." ) return_address = _return_address_for_order(order) if not ( return_address["address_line1"] and return_address["city_locality"] and return_address["state_province"] and return_address["postal_code"] and return_address["phone"] ): raise ShipStationSendError( f"No return warehouse address configured for '{order.company}'. " "Add it in Settings under Return Labels." ) info = order.shipping_info or {} if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")): raise ShipStationSendError( "This ticket is missing the customer's address information - " "can't create a return label without knowing where it ships from." ) return api_key, carrier_id, service_code, store_id def create_dummy_shipment(order: Order) -> dict: """ STEP 1 of the emailed-return-label workflow, callable and verifiable on its own: creates the minimal, cheap outbound label (1x1x1in, 1oz) that your team already creates by hand in ShipStation's GUI before generating a return from it. Returns the FULL response (not just the label_id) so the caller/UI can show it for verification in ShipStation before proceeding to step 2 - splitting these apart on purpose so a problem in one step doesn't get masked by the other. """ api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order) ticket_number = order.ticket_number or order.external_id return _create_dummy_outbound_label( order, api_key, carrier_id, service_code, store_id, external_shipment_id=f"{ticket_number}-DUMMY" ) def create_return_label_from_dummy( order: Order, dummy_label_id: str, packages: List[dict], charge_event: str | None = None ) -> dict: """ STEP 2 of the emailed-return-label workflow: creates the real return label, linked via outbound_label_id to a dummy that was already created (and ideally already verified in ShipStation) in step 1. Does NOT create a new dummy - that's the point of splitting this out. """ settings = config.load_settings() api_key, carrier_id, service_code, store_id = _validate_return_label_prerequisites(order) return_address = _return_address_for_order(order) info = order.shipping_info or {} if not packages: raise ShipStationSendError("At least one package is required.") resolved_charge_event = charge_event or settings.get( "SHIPSTATION_RETURN_CHARGE_EVENT", config.DEFAULT_RETURN_CHARGE_EVENT ) if resolved_charge_event not in VALID_CHARGE_EVENTS: raise ShipStationSendError( f"charge_event must be one of {sorted(VALID_CHARGE_EVENTS)}, got " f"'{resolved_charge_event}'." ) ticket_number = order.ticket_number or order.external_id payload = { "external_shipment_id": ticket_number, "is_return_label": True, "outbound_label_id": dummy_label_id, "charge_event": resolved_charge_event, "shipment": { "carrier_id": carrier_id, "service_code": service_code, "store_id": store_id, "ship_to": return_address, "ship_from": { "name": info.get("name", ""), "phone": info.get("phone", ""), "address_line1": info.get("address1", ""), "address_line2": info.get("address2", "") or None, "city_locality": info.get("city", ""), "state_province": info.get("state", ""), "postal_code": info.get("zip", ""), "country_code": "US", }, "packages": [ _build_return_package( p.get("weight_oz", 1.0), p.get("length", 0), p.get("width", 0), p.get("height", 0) ) for p in packages ], }, } data = _post_to_labels(payload, api_key) data["_dummy_outbound_label_id"] = dummy_label_id return data