Skip to content

Migrated the FedEx carrier from SOAP to the REST API - #1193

Open
fballiano wants to merge 5 commits into
mainfrom
fedex-rest-migration
Open

Migrated the FedEx carrier from SOAP to the REST API#1193
fballiano wants to merge 5 commits into
mainfrom
fedex-rest-migration

Conversation

@fballiano

@fballiano fballiano commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #918.

FedEx has been retiring its SOAP web services, so the carrier now talks REST. Mirrors the USPS REST carrier: a thin Fedex/OAuthClient plus Fedex/RestClient on Symfony HttpClient, no new Composer dependency. The three WSDLs are gone, along with _createSoapClient, getVersionInfo, _getAuthDetails and the already-dead _parseXmlResponse.

Credentials move from meter number / key / password to an OAuth2 client id and secret, both encrypted. Tokens are cached under a fedex_oauth tag for expires_in - 300.

Decisions worth reviewing

Renamed config values are migrated, not aliased. The five SOAP dropoff types map onto REST pickup types, and INTERNATIONAL_PRIORITY becomes FEDEX_INTERNATIONAL_PRIORITY in allowed_methods and free_method. Aliasing at read time would have collapsed FEDEX_INTERNATIONAL_PRIORITY and FEDEX_INTERNATIONAL_PRIORITY_EXPRESS onto one code, letting one differently-priced service silently overwrite the other.

New rate_endpoint setting, because FedEx exposes two rate products: /rate/v1/rates/quotes, and /rate/v1/comprehensiverates/quotes which registered Integrator Providers are required to use and which 403s on the other path. Both take the same payload and return the same shape, so one builder and one parser serve both. Defaults to standard, which is what a normal shipping account wants.

Freight codes dropped from the default allowed_methods — the Rate API doesn't quote freight, and FedEx Freight became a separate company in June 2026. Labels stay in getCode('method') so existing configs still render.

Bugs found while testing

rollBack() claimed success on a refused cancel. FedEx refuses a cancellation with HTTP 200, no errors[], and output.cancelledShipment: false. The old code ignored the response and returned true regardless, so a label left live during a multi-package failure was reported as rolled back — a label the merchant still gets billed for. It now checks the flag per package, logs the reason, and still attempts the remaining packages before returning false.

carriers/fedex/unit_of_measure had no default in config.xml, so it sent weight.units: null, which FedEx rejects outright. An unsaved FedEx config was unquotable. Pre-existing, not introduced here; defaults to LB now with a regression test.

Testing

44 unit tests over payload building, response parsing and rollback, with the rate and tracking parsers checked against real captured sandbox responses committed as fixtures.

6 integration tests hit the live sandbox, gated on FEDEX_SANDBOX_* org secrets so they skip where credentials are absent:

  • OAuth token exchange and cache reuse
  • tracking, raw and through the carrier
  • rates, raw and through collectRates() as checkout calls it
  • label creation, asserted to be a real PDF, then cancelled again
  • a refused cancellation, asserting the HTTP-200-with-false shape that rollBack() has to notice

The sandbox returns SERVICE.UNAVAILABLE.ERROR and SYSTEM.UNEXPECTED.ERROR at random on byte-identical payloads, roughly one call in three. The integration tests retry those two codes with backoff; every other error fails on the first attempt. Production code does not retry, to avoid doubling checkout latency.

Migration verified against a seeded database: SOAP credential rows deleted, dropoff and service codes rewritten, INTERNATIONAL_PRIORITY_FREIGHT and EUROPE_FIRST_INTERNATIONAL_PRIORITY correctly untouched.

Replaces the three SOAP services (RateService_v10, ShipService_v10,
TrackService_v5) and their shipped WSDLs with FedEx's REST API, following the
USPS REST carrier: a thin OAuthClient plus RestClient on Symfony HttpClient,
with no new Composer dependency.

Credentials move from meter number / key / password to an OAuth2 client id and
secret, both encrypted. The upgrade script deletes the obsolete rows so no stale
encrypted SOAP secret is left behind.

Config values that REST renamed are migrated rather than aliased at read time:
the five SOAP dropoff types map onto REST pickup types, and INTERNATIONAL_PRIORITY
becomes FEDEX_INTERNATIONAL_PRIORITY in allowed_methods and free_method. Aliasing
would have collapsed FEDEX_INTERNATIONAL_PRIORITY and
FEDEX_INTERNATIONAL_PRIORITY_EXPRESS onto one code, letting one differently-priced
service overwrite the other.

Adds a rate_endpoint setting because FedEx exposes two rate products: the standard
Rates and Transit Times API, and the Comprehensive one that registered Integrator
Providers are required to use and which answers 403 on the other path. Both take
the same payload and return the same shape, so one builder and one parser serve
both. Defaults to standard.

Also fixes unit_of_measure having no default at all, which sent weight.units as
null and made an unsaved FedEx config unquotable.
The shipment and cancel endpoints work against the sandbox once the request
carries an account authorised for shipping, so both paths now have live coverage:
a real label is created, asserted to be a PDF, and cancelled again.

Testing that surfaced a bug in rollBack(). FedEx refuses a cancel with HTTP 200,
no errors[], and output.cancelledShipment false, but rollBack() ignored the
response and returned true unconditionally, so a label left live during a
multi-package failure was silently reported as rolled back. It now checks the
flag per package, logs the reason FedEx gave, and still attempts the remaining
packages before returning false.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate FedEx SOAP integration to REST (whatarmy/fedex-rest optional dependency)

1 participant