Aller au contenu principal
Making DigitalMaking Digital
Note technique

Tarifs UPS et FedEx en temps réel dans Laravel : une structure qui tient en production

En bref

Pour afficher des tarifs UPS et FedEx dans Laravel, mettez chaque transporteur derrière une interface commune, mettez le jeton OAuth en cache, gardez des délais courts, rejetez toute réponse sans prix et affichez les tarifs de l'autre transporteur si l'un tombe. Le code ci-dessous donne la structure ; les adresses et le format des requêtes sont à prendre sur les portails développeurs des transporteurs.

Un client veut voir le prix de livraison avant de valider son panier, ou un commercial veut chiffrer un envoi sans ouvrir deux sites de transporteurs. UPS et FedEx proposent chacun une API de tarification. Le plus délicat n'est pas l'appel lui-même mais la structure autour : jetons, pannes, cache, tests. Cette note reprend les principes d'un article publié sur dev.to par Aqib Javaid, avec du code adapté à Laravel.

Comment s'authentifier auprès des deux transporteurs ?

Les deux utilisent OAuth 2.0 (flux "client credentials") : vous échangez un identifiant et un secret contre un jeton qui expire. Commencez dans l'environnement de test (sandbox) du transporteur, puis validez avec de vrais comptes. Les adresses des appels sont à relever sur les portails développeurs (developer.ups.com et developer.fedex.com) : nous les mettons en configuration plutôt qu'en dur.

.env
# .env
FEDEX_CLIENT_ID=
FEDEX_CLIENT_SECRET=
FEDEX_TOKEN_URL=
FEDEX_RATES_URL=

Plusieurs détails relevés dans des guides d'intégration, à confirmer sur les portails car ce sont des sources secondaires :

  • UPS a cessé de délivrer des clés d'accès en juin 2023, et l'ancienne authentification devait prendre fin le 3 juin 2024. Une intégration encore fondée sur une clé d'accès ne fonctionne donc plus : il faut des identifiants OAuth créés sur le portail UPS (une clé de l'ancien système ne se convertit pas).
  • FedEx sépare les identifiants et les adresses de test (sandbox) de ceux de production. Les mélanger provoque des erreurs 401. Le jeton dure environ une heure, et le point d'accès qui le délivre est limité en fréquence : d'où l'intérêt du cache.
  • Dans les deux cas, la demande de jeton se fait avec un corps de type formulaire, pas en JSON. Chez UPS, l'identifiant et le secret passent dans un en-tête d'authentification de base (Basic).

Voici l'équivalent côté UPS :

PHP
// app/Shipping/UpsRateProvider.php (excerpt)
private function token(): string
{
    if ($token = Cache::get('ups.token')) {
        return $token;
    }

    // Client id and secret travel in a Basic auth header, the body is a form
    $data = Http::withBasicAuth(config('services.ups.client_id'), config('services.ups.client_secret'))
        ->asForm()
        ->timeout(8)
        ->post(config('services.ups.token_url'), ['grant_type' => 'client_credentials'])
        ->throw()
        ->json();

    Cache::put('ups.token', $data['access_token'], max((int) $data['expires_in'] - 60, 60));

    return $data['access_token'];
}
Tarifs publics ou tarifs négociés ?

Un guide d'intégration UPS signale un piège : il faut demander explicitement le type de tarif voulu dans la requête. Sans cela, les tarifs négociés de votre compte peuvent ne pas apparaître, et vous affichez des prix publics à vos clients.

Comment garder le code simple avec deux transporteurs ?

Une interface commune, et un objet de résultat identique pour tous : le reste de l'application ne sait pas quel transporteur répond.

PHP
// app/Shipping/RateProvider.php
namespace App\Shipping;

interface RateProvider
{
    /** @return RateQuote[] */
    public function quote(ShipmentRequest $request): array;
}
PHP
// app/Shipping/RateQuote.php
namespace App\Shipping;

final readonly class RateQuote
{
    public function __construct(
        public string $carrier,
        public string $service,
        public float $price,
        public string $currency,
        public ?int $transitDays = null,
    ) {}
}

Chaque transporteur a sa classe qui traduit la demande et normalise la réponse. Voici celle de FedEx, avec le jeton en cache et un seul nouvel essai si le jeton a expiré :

PHP
// app/Shipping/FedExRateProvider.php
namespace App\Shipping;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class FedExRateProvider implements RateProvider
{
    public function quote(ShipmentRequest $request): array
    {
        $response = $this->client()->post(config('services.fedex.rates_url'), $this->payload($request));

        if ($response->status() === 401) {
            // Expired token: clear the cache and retry once
            Cache::forget('fedex.token');
            $response = $this->client()->post(config('services.fedex.rates_url'), $this->payload($request));
        }

        return $this->normalize($response->throw()->json());
    }

    private function client(): PendingRequest
    {
        return Http::withToken($this->token())->timeout(8)->retry(2, 200, throw: false);
    }

    private function token(): string
    {
        if ($token = Cache::get('fedex.token')) {
            return $token;
        }

        $data = Http::asForm()->timeout(8)->post(config('services.fedex.token_url'), [
            'grant_type' => 'client_credentials',
            'client_id' => config('services.fedex.client_id'),
            'client_secret' => config('services.fedex.client_secret'),
        ])->throw()->json();

        // Keep the token a little less than its lifetime
        Cache::put('fedex.token', $data['access_token'], max($data['expires_in'] - 60, 60));

        return $data['access_token'];
    }

    private function payload(ShipmentRequest $request): array
    {
        // Build the body described on the carrier developer portal
        return [];
    }

    private function normalize(array $data): array
    {
        // Map each rate to a RateQuote; skip any rate without a price instead of showing 0
        return [];
    }
}

Que faire quand un transporteur tombe en panne ?

Affichez les tarifs de l'autre. Le service qui réunit les transporteurs attrape l'erreur de chacun, la journalise et continue. Il met aussi en cache chaque réponse séparément, pour ne pas garder pendant cinq minutes une liste incomplète.

PHP
// app/Shipping/RateService.php
namespace App\Shipping;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Throwable;

class RateService
{
    /** @param RateProvider[] $providers */
    public function __construct(private array $providers) {}

    public function quotes(ShipmentRequest $request): Collection
    {
        $hash = md5(json_encode($request));

        return collect($this->providers)
            ->flatMap(function (RateProvider $provider) use ($request, $hash) {
                try {
                    // Short cache: rates can change, check each carrier's terms
                    return Cache::remember('rates:'.$provider::class.':'.$hash, 300, fn () => $provider->quote($request));
                } catch (Throwable $e) {
                    report($e);

                    return [];
                }
            })
            ->sortBy('price')
            ->values();
    }
}

Si les deux échouent, enregistrez le devis "en attente" ou laissez un collaborateur le traiter à la main : mieux vaut cela qu'un prix faux. Pour gagner du temps, les deux appels peuvent partir en parallèle avec Http::pool(), à condition d'avoir déjà obtenu les jetons.

Comment tester sans appeler les transporteurs ?

Avec Http::fake() et des réponses réelles enregistrées une fois dans un fichier de test.

PHP
// tests/Feature/RateServiceTest.php
Http::fake([
    'fedex.test/oauth/token' => Http::response(['access_token' => 'tok', 'expires_in' => 3600]),
    'fedex.test/rates' => Http::response(json_decode(file_get_contents(base_path('tests/Fixtures/fedex-rates.json')), true)),
]);

Validez aussi les entrées (codes postaux, poids, dimensions) avant l'appel, et gardez une trace de chaque devis présenté au client. Pour relier vos commandes à vos transporteurs et à votre logiciel de gestion, voir connecter vos logiciels.

À retenir
  • Une interface commune par transporteur et un objet de résultat unique.
  • Jeton OAuth en cache, un seul nouvel essai sur une erreur 401, délais courts.
  • Jamais de prix à zéro : rejetez les réponses sans prix.
  • Si un transporteur échoue, affichez l'autre ; testez avec Http::fake().

Questions fréquentes

Peut-on encore utiliser une clé d'accès UPS ?

Non, d'après les guides d'intégration : UPS a cessé de délivrer des clés d'accès en juin 2023 et l'ancienne authentification devait prendre fin le 3 juin 2024. Il faut des identifiants OAuth créés sur le portail développeurs UPS.

Où trouver les adresses des API UPS et FedEx ?

Sur les portails développeurs des transporteurs (developer.ups.com et developer.fedex.com). Elles peuvent changer, c'est pourquoi nous les mettons en configuration.

Faut-il mettre les tarifs en cache ?

Oui, pour une courte durée et par transporteur. Les tarifs peuvent changer : vérifiez les conditions de chaque transporteur.

Que montrer au client si un transporteur ne répond pas ?

Les tarifs de l'autre. Si aucun ne répond, enregistrez la demande en attente ou faites traiter le devis par un collaborateur.

Making Digital

Nous connectons les logiciels des PME, automatisons leurs tâches répétitives et créons des assistants IA, depuis 2010. Nos articles partent de nos projets réels.

Notre parcours
Une question sur ce sujet ?

Parlons-en 30 minutes.

Vous saurez si votre problème se règle, comment, et en combien de temps. Sans engagement.