Тема
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');
});