Главная/Статьи/СДЭК API — расчёт доставки и создание заказов

СДЭК API — расчёт доставки и создание заказов

Интеграция с СДЭК: расчёт стоимости доставки, создание заказов, отслеживание статусов, выбор ПВЗ. Примеры для PHP и TypeScript.

ДМ
Дмитрий Мещеряков
📅 24 июля 2026 г.📖 9 мин чтения

Интеграция с СДЭК — задача, которую заказчик считает типовой, а по факту она распадается на четыре независимые: расчёт стоимости, выбор пункта выдачи, создание заказа и отслеживание. У каждой свои особенности, и объединяет их одно — все они зависят от доступности чужого сервиса в момент, когда покупатель оформляет заказ.

Разберём клиент API и, отдельно, что делать, когда СДЭК не отвечает: это не гипотетический сценарий, а регулярная часть эксплуатации любой интеграции с доставкой.

СДЭК API v2

СДЭК предоставляет REST API для автоматизации доставки:

  • Расчёт стоимости и сроков
  • Создание заказов на доставку
  • Получение списка ПВЗ
  • Отслеживание статусов
  • Печать накладных
💡 Совет

Аутентификация: OAuth 2.0 с client_id и client_secret, токен живёт 1 час.

Обратите внимание на две вещи в реализации ниже. Токен кэшируется в свойстве объекта — то есть в пределах одного PHP-процесса; при обычной работе сайта это значит запрос за токеном на каждый хит, где нужен расчёт доставки. На нагруженном магазине это лишний внешний запрос в критичном пути. Токен стоит класть в общий кэш (Redis или файловый кэш Битрикса) с временем жизни чуть меньше заявленного.

И тестовый контур (api.edu.cdek.ru) — обязательная часть работы, а не опция: создавать реальные заказы на доставку в процессе отладки не стоит.

PHP: Клиент для СДЭК API

php
<?php
// /local/lib/Delivery/CdekClient.php
namespace Local\Delivery;
class CdekClient
{
    private string $clientId;
    private string $clientSecret;
    private string $baseUrl;
    private ?string $accessToken = null;
    private ?int $tokenExpires = null;
    public function __construct(bool $isTest = false)
    {
        $this->baseUrl = $isTest
            ? 'https://api.edu.cdek.ru/v2/'
            : 'https://api.cdek.ru/v2/';
        $this->clientId = $isTest
            ? getenv('CDEK_TEST_CLIENT_ID')
            : getenv('CDEK_CLIENT_ID');
        $this->clientSecret = $isTest
            ? getenv('CDEK_TEST_CLIENT_SECRET')
            : getenv('CDEK_CLIENT_SECRET');
    }
    /**
     * Получение OAuth токена
     */
    private function getToken(): string
    {
        // Проверяем, не истёк ли токен
        if ($this->accessToken && $this->tokenExpires > time()) {
            return $this->accessToken;
        }
        $ch = curl_init($this->baseUrl . 'oauth/token');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => http_build_query([
                'grant_type' => 'client_credentials',
                'client_id' => $this->clientId,
                'client_secret' => $this->clientSecret,
            ]),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/x-www-form-urlencoded',
            ],
        ]);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $data = json_decode($response, true);

        if ($httpCode !== 200 || empty($data['access_token'])) {
            // Текст ответа — в лог, наружу общее сообщение:
            // в ответе OAuth может оказаться эхо client_id
            AddMessage2Log("CDEK auth failed: HTTP {$httpCode}", 'cdek');

            throw new \RuntimeException('Failed to get CDEK token');
        }
        $this->accessToken = $data['access_token'];
        $this->tokenExpires = time() + ($data['expires_in'] ?? 3600) - 60;
        return $this->accessToken;
    }
    /**
     * Расчёт стоимости доставки
     *
     * ВНИМАНИЕ: вес в граммах, габариты в сантиметрах.
     * Перепутанные единицы — самая частая причина неверных тарифов,
     * и API об ошибке не сообщит: 2 кг, переданные как 2, посчитаются
     * как два грамма и вернут подозрительно дешёвую доставку
     */
    public function calculateDelivery(array $params): array
    {
        return $this->request('POST', 'calculator/tariff', [
            'type' => 1, // 1 - интернет-магазин
            'from_location' => [
                'code' => $params['from_city_code'], // Код города отправителя
            ],
            'to_location' => [
                'code' => $params['to_city_code'], // Код города получателя
            ],
            'packages' => [
                [
                    'weight' => $params['weight'], // Вес в граммах
                    'length' => $params['length'] ?? 10,
                    'width' => $params['width'] ?? 10,
                    'height' => $params['height'] ?? 10,
                ],
            ],
        ]);
    }
    /**
     * Расчёт по всем тарифам
     */
    public function calculateAllTariffs(array $params): array
    {
        return $this->request('POST', 'calculator/tarifflist', [
            'type' => 1,
            'from_location' => [
                'code' => $params['from_city_code'],
            ],
            'to_location' => [
                'code' => $params['to_city_code'],
            ],
            'packages' => [
                [
                    'weight' => $params['weight'],
                    'length' => $params['length'] ?? 10,
                    'width' => $params['width'] ?? 10,
                    'height' => $params['height'] ?? 10,
                ],
            ],
        ]);
    }
    /**
     * Создание заказа
     */
    public function createOrder(array $orderData): array
    {
        return $this->request('POST', 'orders', $orderData);
    }
    /**
     * Получение информации о заказе
     */
    public function getOrder(string $uuid): array
    {
        return $this->request('GET', "orders/{$uuid}");
    }
    /**
     * Список ПВЗ
     */
    public function getDeliveryPoints(array $filter = []): array
    {
        $query = http_build_query($filter);
        return $this->request('GET', "deliverypoints?{$query}");
    }
    /**
     * Поиск города по названию
     */
    public function findCity(string $name): array
    {
        return $this->request('GET', 'location/cities?' . http_build_query([
            'city' => $name,
            'size' => 10,
        ]));
    }
    private function request(string $method, string $endpoint, ?array $data = null): array
    {
        $token = $this->getToken();
        $ch = curl_init($this->baseUrl . $endpoint);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Content-Type: application/json',
            ],
        ]);
        if ($method === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            if ($data) {
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
            }
        }
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($httpCode >= 400) {
            $error = json_decode($response, true);
            throw new \RuntimeException(
                $error['errors'][0]['message'] ?? 'CDEK API error',
                $httpCode
            );
        }
        return json_decode($response, true) ?? [];
    }
}

Расчёт доставки для корзины

php
<?php
use Local\Delivery\CdekClient;
class DeliveryCalculator
{
    private CdekClient $cdek;
    private int $fromCityCode = 44; // Москва
    public function __construct()
    {
        $this->cdek = new CdekClient(isTest: true);
    }
    /**
     * Расчёт доставки для корзины
     */
    public function calculate(int $toCityCode, array $basketItems): array
    {
        // Считаем общий вес
        $totalWeight = 0;
        foreach ($basketItems as $item) {
            $totalWeight += ($item['WEIGHT'] ?? 500) * $item['QUANTITY'];
        }
        // Минимальный вес 100г
        $totalWeight = max(100, $totalWeight);
        $result = $this->cdek->calculateAllTariffs([
            'from_city_code' => $this->fromCityCode,
            'to_city_code' => $toCityCode,
            'weight' => $totalWeight,
        ]);
        // Форматируем ответ
        $deliveryOptions = [];
        foreach ($result['tariff_codes'] ?? [] as $tariff) {
            $deliveryOptions[] = [
                'code' => $tariff['tariff_code'],
                'name' => $tariff['tariff_name'],
                'price' => $tariff['delivery_sum'],
                'min_days' => $tariff['period_min'],
                'max_days' => $tariff['period_max'],
                'type' => $this->getTariffType($tariff['tariff_code']),
            ];
        }
        // Сортируем по цене
        usort($deliveryOptions, fn($a, $b) => $a['price'] <=> $b['price']);
        return $deliveryOptions;
    }
    private function getTariffType(int $code): string
    {
        // Тарифы до ПВЗ
        $pvzTariffs = [136, 137, 138, 139, 366, 368, 378, 1009];
        // Тарифы до двери
        $doorTariffs = [137, 139, 233, 234, 291, 294];
        if (in_array($code, $pvzTariffs)) return 'pvz';
        if (in_array($code, $doorTariffs)) return 'door';
        return 'unknown';
    }
}
// Использование
$calculator = new DeliveryCalculator();
$options = $calculator->calculate(
    toCityCode: 137, // Санкт-Петербург
    basketItems: $basketItems
);
foreach ($options as $option) {
    echo "{$option['name']}: {$option['price']} руб., {$option['min_days']}-{$option['max_days']} дн.\n";
}

Создание заказа

php
<?php
use Local\Delivery\CdekClient;
function createCdekOrder(array $orderData): string
{
    $cdek = new CdekClient(isTest: true);
    $request = [
        'type' => 1, // Интернет-магазин
        'number' => 'ORDER-' . $orderData['order_id'],
        'tariff_code' => $orderData['tariff_code'],
        'sender' => [
            'company' => 'ООО Моя компания',
            'name' => 'Иванов Иван',
            'phones' => [['number' => '+79001234567']],
        ],
        'recipient' => [
            'name' => $orderData['customer_name'],
            'phones' => [['number' => $orderData['customer_phone']]],
            'email' => $orderData['customer_email'],
        ],
        'from_location' => [
            'code' => 44, // Москва
            'address' => 'ул. Складская, 1',
        ],
        // Доставка до ПВЗ
        'delivery_point' => $orderData['pvz_code'],
        // Или до двери:
        // 'to_location' => [
        //     'code' => $orderData['city_code'],
        //     'address' => $orderData['address'],
        // ],
        'packages' => [
            [
                'number' => 'PACK-1',
                'weight' => $orderData['total_weight'],
                'length' => 30,
                'width' => 20,
                'height' => 10,
                'items' => array_map(function ($item) {
                    return [
                        'name' => $item['name'],
                        'ware_key' => $item['article'],
                        'payment' => ['value' => 0], // Оплачено онлайн
                        'cost' => $item['price'],
                        'weight' => $item['weight'],
                        'amount' => $item['quantity'],
                    ];
                }, $orderData['items']),
            ],
        ],
    ];
    $response = $cdek->createOrder($request);
    if (empty($response['entity']['uuid'])) {
        throw new \RuntimeException('Failed to create CDEK order');
    }
    return $response['entity']['uuid'];
}

Виджет выбора ПВЗ

html
<!-- Подключаем виджет СДЭК -->
<script src="https://cdn.cdek.ru/widget/delivery/widget.js"></script>
<div id="cdek-map"></div>
<script>
const widget = new CDEKWidget({
    apiKey: 'ваш_api_key',
    selector: '#cdek-map',
    servicePath: '/api/cdek-service', // Ваш прокси-эндпоинт
    from: {
        country_code: 'RU',
        city: 'Москва',
        postal_code: '101000',
    },
    tariffs: {
        office: [136, 138], // До ПВЗ
        door: [137, 139],   // До двери
    },
    defaultLocation: {
        country_code: 'RU',
        city: 'Москва',
    },
    onChoose: (data, type) => {
        console.log('Выбрано:', data);
        // data.address - адрес
        // data.code - код ПВЗ
        // data.city_code - код города
        document.getElementById('delivery_pvz').value = data.code;
        document.getElementById('delivery_address').value = data.address;
        document.getElementById('delivery_price').value = data.delivery_sum;
    },
});
</script>

TypeScript: Next.js интеграция

typescript
// src/lib/cdek.ts
type CdekConfig = {
  clientId: string;
  clientSecret: string;
  isTest: boolean;
};
type CalculateParams = {
  fromCityCode: number;
  toCityCode: number;
  weight: number;
  length?: number;
  width?: number;
  height?: number;
};
type DeliveryOption = {
  tariffCode: number;
  tariffName: string;
  deliverySum: number;
  periodMin: number;
  periodMax: number;
};
export class CdekClient {
  private baseUrl: string;
  private clientId: string;
  private clientSecret: string;
  private accessToken: string | null = null;
  private tokenExpires: number = 0;
  constructor(config: CdekConfig) {
    this.baseUrl = config.isTest
      ? 'https://api.edu.cdek.ru/v2/'
      : 'https://api.cdek.ru/v2/';
    this.clientId = config.clientId;
    this.clientSecret = config.clientSecret;
  }
  private async getToken(): Promise<string> {
    if (this.accessToken && this.tokenExpires > Date.now()) {
      return this.accessToken;
    }
    const response = await fetch(`${this.baseUrl}oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.clientId,
        client_secret: this.clientSecret,
      }),
    });
    const data = await response.json();
    if (!data.access_token) {
      throw new Error('Failed to get CDEK token');
    }
    this.accessToken = data.access_token;
    this.tokenExpires = Date.now() + (data.expires_in - 60) * 1000;
    return this.accessToken;
  }
  async calculateDelivery(params: CalculateParams): Promise<DeliveryOption[]> {
    const token = await this.getToken();
    const response = await fetch(`${this.baseUrl}calculator/tarifflist`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        type: 1,
        from_location: { code: params.fromCityCode },
        to_location: { code: params.toCityCode },
        packages: [
          {
            weight: params.weight,
            length: params.length ?? 10,
            width: params.width ?? 10,
            height: params.height ?? 10,
          },
        ],
      }),
    });
    const data = await response.json();
    return (data.tariff_codes || []).map((t: any) => ({
      tariffCode: t.tariff_code,
      tariffName: t.tariff_name,
      deliverySum: t.delivery_sum,
      periodMin: t.period_min,
      periodMax: t.period_max,
    }));
  }
  async getDeliveryPoints(cityCode: number): Promise<any[]> {
    const token = await this.getToken();
    const response = await fetch(
      `${this.baseUrl}deliverypoints?city_code=${cityCode}`,
      {
        headers: { Authorization: `Bearer ${token}` },
      }
    );
    return response.json();
  }
}
⚠️ Важно

Тестовые данные СДЭК:

  • Test client_id: EMscd6r9JnFiQ3bLoyjJY6eM78JrJceI
  • Test client_secret: PjLZkKBHEiLK3YsjtNrt3TGNG0ahs3dG

Что делать, когда СДЭК не отвечает

Самый важный раздел для боевой интеграции, и его почти никогда нет в документации.

Расчёт доставки — в оформлении заказа, а значит в критичном пути. Если запрос к API висит десять секунд, покупатель эти десять секунд смотрит на крутящийся индикатор. Ставьте жёсткий таймаут (2–3 секунды на расчёт) и предусмотрите поведение при его превышении: показать примерную стоимость по своей таблице, предложить «уточнит менеджер» или временно скрыть способ доставки. Что угодно, кроме бесконечного ожидания.

Кэшируйте расчёты. Стоимость доставки для пары «город + вес + габариты» не меняется в течение дня. Кэш на несколько часов убирает большую часть запросов и заодно даёт запасной ответ на случай недоступности API.

Список ПВЗ — не запрашивайте в реальном времени. Их десятки тысяч, ответ весит мегабайты, и меняется он редко. Забирайте раз в сутки фоновым скриптом в свою таблицу и отдавайте виджету из неё: карта откроется мгновенно и будет работать, даже когда API недоступен.

Создание заказа на доставку — асинхронно и с повторами. Заказ покупателя должен создаваться независимо от того, приняла ли его СДЭК. Передача в службу доставки — фоновая задача с повторными попытками и уведомлением менеджеру, если не удалось за N попыток.

Отслеживайте расхождения. Если рассчитанная стоимость систематически отличается от фактической, это заметит бухгалтерия — но через месяц. Логируйте расчёт вместе с заказом, чтобы было с чем сравнивать.

Итоги

ОперацияЭндпоинт
ТокенPOST /oauth/token
Расчёт тарифаPOST /calculator/tariff
Все тарифыPOST /calculator/tarifflist
Создание заказаPOST /orders
Статус заказаGET /orders/{uuid}
ПВЗGET /deliverypoints
ГородаGET /location/cities

Популярные тарифы:

  • 136 — Посылка склад-склад
  • 137 — Посылка склад-дверь
  • 138 — Посылка дверь-склад
  • 139 — Посылка дверь-дверь
🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.