Интеграция с СДЭК — задача, которую заказчик считает типовой, а по факту она распадается на четыре независимые: расчёт стоимости, выбор пункта выдачи, создание заказа и отслеживание. У каждой свои особенности, и объединяет их одно — все они зависят от доступности чужого сервиса в момент, когда покупатель оформляет заказ.
Разберём клиент API и, отдельно, что делать, когда СДЭК не отвечает: это не гипотетический сценарий, а регулярная часть эксплуатации любой интеграции с доставкой.
СДЭК API v2
СДЭК предоставляет REST API для автоматизации доставки:
- Расчёт стоимости и сроков
- Создание заказов на доставку
- Получение списка ПВЗ
- Отслеживание статусов
- Печать накладных
Аутентификация: OAuth 2.0 с client_id и client_secret, токен живёт 1 час.
Обратите внимание на две вещи в реализации ниже. Токен кэшируется в свойстве объекта — то есть в пределах одного PHP-процесса; при обычной работе сайта это значит запрос за токеном на каждый хит, где нужен расчёт доставки. На нагруженном магазине это лишний внешний запрос в критичном пути. Токен стоит класть в общий кэш (Redis или файловый кэш Битрикса) с временем жизни чуть меньше заявленного.
И тестовый контур (api.edu.cdek.ru) — обязательная часть работы, а не опция: создавать реальные заказы на доставку в процессе отладки не стоит.
PHP: Клиент для СДЭК API
<?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
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
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'];
}Виджет выбора ПВЗ
<!-- Подключаем виджет СДЭК -->
<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 интеграция
// 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.