Skip to content

PHP пример

Production-ready клиент для inbound + handler для callback'ов на чистом PHP / Laravel.

Клиент: залить лид

php
<?php
namespace App\Integrations\CartelCrm;

use GuzzleHttp\Client as Guzzle;

class CartelCrmClient
{
    public function __construct(
        private readonly Guzzle $http,
        private readonly string $baseUrl,
        private readonly string $apiKey,
        private readonly string $hmacSecret,
    ) {}

    /**
     * @return array{status:int, body:array}
     */
    public function submitLead(array $payload, string $idempotencyKey): array
    {
        // Сериализуем один раз — ту же строку подпишем и отправим.
        $body = json_encode(
            $payload,
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
        );

        $ts = (string) time();
        $sig = hash_hmac('sha256', $body, $this->hmacSecret);

        $response = $this->http->post("{$this->baseUrl}/v1/inbound/leads", [
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
                'X-Signature' => "sha256={$sig}",
                'X-Timestamp' => $ts,
                'Idempotency-Key' => $idempotencyKey,
                'Content-Type' => 'application/json',
            ],
            'body' => $body,
            'http_errors' => false,  // не throw на 4xx — мы хотим разобрать ответ
            'timeout' => 15,
        ]);

        return [
            'status' => $response->getStatusCode(),
            'body' => json_decode((string) $response->getBody(), true) ?? [],
        ];
    }

    public function getStatus(string $externalId): array
    {
        $ts = (string) time();
        // Подпись пустой строки
        $sig = hash_hmac('sha256', '', $this->hmacSecret);

        $response = $this->http->get(
            "{$this->baseUrl}/v1/inbound/leads/" . rawurlencode($externalId) . '/status',
            [
                'headers' => [
                    'Authorization' => "Bearer {$this->apiKey}",
                    'X-Signature' => "sha256={$sig}",
                    'X-Timestamp' => $ts,
                ],
                'http_errors' => false,
                'timeout' => 10,
            ],
        );

        return [
            'status' => $response->getStatusCode(),
            'body' => json_decode((string) $response->getBody(), true) ?? [],
        ];
    }
}

Регистрация (Laravel)

php
// config/services.php
'cartelcrm' => [
    'base_url' => env('CARTELCRM_BASE_URL', 'https://cartelcrm.com/api'),
    'api_key' => env('CARTELCRM_API_KEY'),
    'hmac_secret' => env('CARTELCRM_HMAC_SECRET'),
    'callback_secret' => env('CARTELCRM_CALLBACK_SECRET'),
],
php
// app/Providers/AppServiceProvider.php
$this->app->singleton(CartelCrmClient::class, function () {
    return new CartelCrmClient(
        new \GuzzleHttp\Client(),
        config('services.cartelcrm.base_url'),
        config('services.cartelcrm.api_key'),
        config('services.cartelcrm.hmac_secret'),
    );
});

Использование

php
// app/Jobs/SubmitLeadToCartelCrm.php
public function handle(CartelCrmClient $client): void
{
    $result = $client->submitLead([
        'external_id' => $this->lead->external_id,
        'phone' => $this->lead->phone,
        'first_name' => $this->lead->first_name,
        'country' => $this->lead->country,
        'source' => $this->lead->source,
    ], $this->lead->external_id . '-' . $this->attempts());

    match (true) {
        $result['status'] === 200 => $this->lead->markSentTo('cartelcrm', $result['body']['id']),
        $result['status'] === 409 => $this->lead->markDuplicate('cartelcrm', $result['body']['id']),
        $result['status'] === 429 => throw new \RuntimeException('rate_limited'),  // job retry
        $result['status'] >= 500 => $this->release(60),  // retry через минуту
        default => $this->lead->markRejected('cartelcrm', $result['body']['reason'] ?? 'unknown'),
    };
}

Сервер: callback handler

Laravel route + middleware

php
// routes/api.php
Route::post('/cartelcrm/callback', [CartelCrmCallbackController::class, 'handle'])
    ->middleware('cartelcrm.signature');

Middleware: проверка подписи

php
// app/Http/Middleware/VerifyCartelCrmSignature.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class VerifyCartelCrmSignature
{
    public function handle(Request $request, Closure $next)
    {
        $rawBody = $request->getContent();
        $sigHeader = (string) $request->header('X-Signature', '');
        $ts = (int) $request->header('X-Timestamp', 0);

        // Anti-replay (±5 минут)
        if (!$ts || abs(time() - $ts) > 300) {
            return response()->json(['error' => 'timestamp_out_of_range'], 401);
        }

        $sig = str_starts_with($sigHeader, 'sha256=')
            ? substr($sigHeader, 7)
            : $sigHeader;

        $expected = hash_hmac(
            'sha256',
            $rawBody,
            config('services.cartelcrm.callback_secret'),
        );

        if (!hash_equals($expected, $sig)) {
            return response()->json(['error' => 'bad_signature'], 403);
        }

        return $next($request);
    }
}
php
// bootstrap/app.php
$middleware->alias([
    'cartelcrm.signature' => \App\Http\Middleware\VerifyCartelCrmSignature::class,
]);

Controller

php
// app/Http/Controllers/CartelCrmCallbackController.php
namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CartelCrmCallbackController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        $event = $request->json()->all();

        // Идемпотентность: ключ — (internal_id, event, timestamp)
        $idKey = sprintf(
            '%s:%s:%s',
            $event['internal_id'] ?? 'unknown',
            $event['event'] ?? 'unknown',
            $event['timestamp'] ?? 'unknown',
        );

        if (\Cache::has("cartelcrm:callback:{$idKey}")) {
            return response()->json(['ok' => true, 'replay' => true]);
        }
        \Cache::put("cartelcrm:callback:{$idKey}", true, now()->addDay());

        \App\Jobs\ProcessCartelCrmCallback::dispatch($event);

        return response()->json(['ok' => true]);
    }
}

Не парсите body в middleware

$request->json() парсит — но getContent() отдаёт сырое. Подпись считайте только от getContent(), иначе при наличии символов unicode / порядка ключей подпись «поплывёт».

Тестирование подписи

php
// tests/Feature/CartelCrmHmacTest.php
test('HMAC matches PHP and openssl', function () {
    $body = '{"phone":"+380501234567"}';
    $secret = 'test_secret';

    $ours = hash_hmac('sha256', $body, $secret);

    // Сверьте с `echo -n '{"phone":"+380501234567"}' | openssl dgst -sha256 -hmac test_secret`
    expect($ours)->toBe('expected_hex_here');
});

Закрытая партнёрская документация. Не для публичного распространения.