homlity/chat-sdk

SDK PHP para la API de Chat Homlity: conversaciones de WhatsApp, asistente de IA, campañas y webhooks firmados para plataformas multi-inquilino.

Maintainers

Package info

github.com/homlity/sdk-chat-homlity

Homepage

pkg:composer/homlity/chat-sdk

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.0.1 2026-08-23 13:25 UTC

This package is not auto-updated.

Last update: 2026-08-24 05:24:59 UTC


README

Cliente PHP de la API de Chat Homlity: conversaciones de WhatsApp con asistente de IA, relevo a un asesor humano, campañas por plantilla y webhooks firmados.

Está pensado para una plataforma —un producto como Homlity Web, Codi o Awali— que tiene inmobiliarias como clientes y quiere ofrecerles chat de WhatsApp dentro de su propia interfaz.

composer require homlity/chat-sdk

Requiere PHP 8.1+ y las extensiones curl, json, hash y openssl. Sin dependencias de terceros: trae su propio cliente HTTP sobre cURL, y admite cualquier cliente PSR-18 si prefieres el tuyo.

⚠️ Lo primero: el secreto de plataforma nunca va al navegador

El secreto HMAC de tu plataforma permite emitir tokens para cualquiera de tus clientes. Si se filtra, quien lo tenga puede leer y escribir en las conversaciones de todas tus inmobiliarias.

Por eso el SDK expone dos clientes separados, en dos namespaces distintos:

Homlity\Chat\Platform\PlatformClient Homlity\Chat\Account\AccountClient
Credencial Secreto HMAC de larga vida Token Bearer corto y revocable
Dónde vive Solo en tu backend Backend, frontend o sesión del asesor
Qué hace Emite tokens, registra webhooks Conversaciones, mensajes, IA, campañas
Endpoints de tokens y webhooks No existen en esta clase

AccountClient no tiene ningún parámetro donde quepa un secreto de plataforma, y no expone los métodos accessTokens() ni webhooks(). La separación es estructural, no una advertencia en la documentación.

Inicio rápido A — backend de la plataforma

Tu backend firma con HMAC y emite un token corto para la sesión de un asesor.

use Homlity\Chat\Enum\Ability;
use Homlity\Chat\Platform\PlatformClient;

$platform = new PlatformClient(
    platformSlug: 'codi',                        // el slug que te asignó Homlity
    platformSecret: getenv('CHAT_PLATFORM_SECRET'),
);

$token = $platform->accessTokens()->issue(
    accountId: '42',                             // TU id de cliente (la inmobiliaria)
    name: 'Codi - sesión asesor',
    abilities: Ability::advisorSession(),        // el conjunto mínimo
    expiresInMinutes: 15,
    userId: '7',                                 // TU id del asesor que actúa
);

return response()->json($token->forBrowser());

forBrowser() devuelve exactamente lo que puede salir hacia el navegador:

{
  "token": "hchat_...",
  "token_type": "Bearer",
  "expires_at": "2026-08-23T10:15:00+00:00",
  "abilities": ["conversations:read", "conversations:takeover", "messages:send", "realtime:read"]
}

Dos cosas que conviene saber:

  • No hay aprovisionamiento previo. La cuenta se crea sola la primera vez que nombras un accountId. La cuenta es única por el par (plataforma, accountId), así que dos plataformas pueden usar el mismo número de cliente sin colisionar.
  • El token en claro aparece una sola vez. Si lo pierdes, emite otro; revocar el anterior es revoke().

El accountId y el userId deben salir de tu sesión, nunca del cuerpo de la petición del navegador: quien decide para qué cliente se emite un token eres tú.

Ejemplo completo: examples/01_backend_emitir_token.php.

Inicio rápido B — sesión del asesor

Con el token del paso anterior, y sin saber nada del secreto:

use Homlity\Chat\Account\AccountClient;
use Homlity\Chat\Enum\ConversationStatus;

$chat = new AccountClient($tokenQueEmitioTuBackend);

// Iteración perezosa: una petición por página, solo cuando hace falta.
foreach ($chat->conversations()->all(status: ConversationStatus::OPEN) as $conversacion) {
    echo $conversacion['uuid'], ' ', $conversacion['contact']['display_name'] ?? '', PHP_EOL;
}

$enviado = $chat->conversations()->sendMessage(
    conversationUuid: '550e8400-e29b-41d4-a716-446655440000',
    text: 'Hola, soy tu asesor. ¿En qué zona estás buscando?',
    takeOver: true,      // toma el control en la misma llamada
);

$enviado->clientMessageId;   // el SDK lo generó; guárdalo si vas a reintentar
$enviado->isDuplicate;       // true si el servicio devolvió el mensaje original (200)

take_over: si la conversación la lleva la IA y envías sin tomar el control, el servicio responde 409. Con takeOver: true se hace todo en una llamada. El SDK traduce ese 409 a ConversationNotControlledException, con la sugerencia en el mensaje.

Idempotencia: client_message_id es obligatorio en la API y el SDK lo genera si no lo aportas, exponiéndolo en el resultado. Reenviar el mismo id devuelve 200 con el mensaje original en vez de duplicarlo; un envío nuevo devuelve 201. Es lo que protege de un doble clic o del reintento de una petición que sí llegó.

Ejemplo completo: examples/02_sesion_asesor.php.

Webhooks

1. Registrar el endpoint

use Homlity\Chat\Enum\WebhookEventType;

$registro = $platform->webhooks()->register(
    url: 'https://api.codi.com/hooks/chat',       // debe ser https
    events: [WebhookEventType::MESSAGE_RECEIVED, WebhookEventType::HANDOFF_REQUESTED],
    // scope: WebhookScope::PLATFORM  (por defecto)
);

$registro->secret;   // ⚠️ llega UNA SOLA VEZ: guárdalo cifrado

Omitir events suscribe a todos. Con WebhookScope::ACCOUNT registras un endpoint solo para el cliente que nombres, que sustituye al de plataforma para esa cuenta.

Eventos: message.received, message.sent, message.status_changed, conversation.created, conversation.mode_changed, handoff.requested.

2. Recibir y verificar

Cada entrega llega con estas cabeceras:

X-Chat-Event:       message.received
X-Chat-Delivery-Id: 019f0e2a...
X-Chat-Timestamp:   1787440000
X-Chat-Signature:   sha256=4f53cda...

La firma es HMAC-SHA256(timestamp + "." + cuerpo_crudo, secreto_del_webhook).

use Homlity\Chat\Exception\WebhookVerificationException;
use Homlity\Chat\Webhook\DeliveryDeduplicator;
use Homlity\Chat\Webhook\Store\Psr16DeliveryStore;
use Homlity\Chat\Webhook\WebhookVerifier;

$verificador = new WebhookVerifier($secretoGuardado);
$dedup = new DeliveryDeduplicator(new Psr16DeliveryStore(cache()->store()));

// 1. Cuerpo CRUDO. En PHP puro, php://input.
$cuerpoCrudo = file_get_contents('php://input');

try {
    $evento = $verificador->verify($cuerpoCrudo, $_SERVER);
} catch (WebhookVerificationException $e) {
    http_response_code(401);
    exit;
}

// 2. Deduplica: la entrega es "al menos una vez".
if (! $dedup->firstTime($evento->deliveryId)) {
    http_response_code(200);
    exit;
}

// 3. Responde 2xx YA y procesa después.
http_response_code(202);
echo '{"ok":true}';
fastcgi_finish_request();

$idDeTuCliente = $evento->accountExternalId();   // con esto enrutas
procesar($evento);

El verificador hace tres cosas por ti: calcula el HMAC sobre el cuerpo crudo, compara en tiempo constante (hash_equals, nunca ==) y rechaza timestamps con más de 300 segundos de desfase, para que una captura no se pueda reproducir más tarde.

Responde 2xx rápido y procesa después. El timeout del servicio es de 15 segundos. Si tardas más, la entrega se da por fallida y se repite: hasta 6 intentos, con esperas de 10 s, 30 s, 1 min, 3 min y 5 min. Encola el trabajo lento y contesta enseguida.

Receptor completo y ejecutable: examples/04_receptor_webhook.php.

Cómo obtener el cuerpo crudo

Framework Cómo
Laravel $request->getContent()
Symfony $request->getContent()
PSR-7 / Slim / Mezzio (string) $request->getBody()
PHP puro file_get_contents('php://input')

Nunca $request->all() ni json_encode($request->json()->all()): al reserializar cambian el orden de las claves, los espacios y el escapado, y la firma deja de cuadrar. Es, con diferencia, el error más común.

Middleware de Laravel

// AppServiceProvider::register()
$this->app->bind(VerifyChatWebhook::class, fn () => new VerifyChatWebhook(
    new WebhookVerifier(config('services.chat_homlity.webhook_secret')),
    new DeliveryDeduplicator(new Psr16DeliveryStore(cache()->store())),
));

// bootstrap/app.php
->withMiddleware(fn (Middleware $m) => $m->alias(['chat.webhook' => VerifyChatWebhook::class]))

// routes/api.php  (en api.php, no en web.php: así no pasa por CSRF)
Route::post('/hooks/chat', ChatWebhookController::class)->middleware('chat.webhook');
final class ChatWebhookController
{
    public function __invoke(Request $request): Response
    {
        $evento = VerifyChatWebhook::eventFrom($request);   // ya verificado y deduplicado

        ProcesarEventoDeChat::dispatch($evento->type, $evento->payload)->afterResponse();

        return response()->noContent();
    }
}

Hay también un middleware PSR-15 en Homlity\Chat\Webhook\Psr15 para Slim y Mezzio. Referencia completa de Laravel: examples/05_laravel.php.

3. Comprobarlo con webhook/test

POST /webhook/test dispara un evento sintético contra tu endpoint y te devuelve exactamente lo que firmó y transmitió. Es la forma de cerrar el círculo a la primera:

$resultado = $platform->webhooks()->test();

echo $resultado->explain();
// "Entregado: tu endpoint respondió 200 en 142 ms. La verificación de firma funciona."

$resultado->matchesLocalSecret($secretoGuardado);

Cómo leer el resultado:

Síntoma Qué significa
delivered: true Todo bien: tu receptor verifica correctamente.
matchesLocalSecret() false Tu secreto no es el que usó el servicio. Vuelve a registrar el webhook y guarda el nuevo.
matchesLocalSecret() true, pero tu endpoint devolvió 401 El secreto es correcto: estás verificando sobre un cuerpo reserializado en vez de sobre el crudo.
Tu endpoint devolvió 5xx Tu receptor falla al procesar. Responde 2xx primero y procesa después.
response_status: null No se pudo conectar. Comprueba que la URL sea https, pública y resoluble desde internet.

Limitado a 6 llamadas por minuto.

Manejo de errores

Cada código HTTP se traduce a un tipo distinto, con un mensaje que dice qué arreglar. Todos implementan Homlity\Chat\Exception\ChatException.

Código Excepción Reintenta Qué hacer
401 firma incorrecta InvalidSignatureException No Revisa secreto, slug, accountId y userId
401 firma vencida ClockSkewException No Sincroniza el reloj (NTP). No es un problema de credenciales
401 token ExpiredTokenException No Pide otro token a tu backend
402 AccountSuspendedException No Facturación, no credenciales
403 PermissionDeniedException No ->missingAbility nombra el permiso que falta
403 en tokens/webhooks HmacRequiredException No Ese endpoint solo acepta HMAC: úsalo desde PlatformClient
404 NotFoundException No No existe, o pertenece a otro inquilino
409 ConversationNotControlledException No Usa takeOver: true o llama antes a takeover()
422 ValidationException No ->errors(), ->errorsFor('text'), ->invalidFields()
429 RateLimitException El SDK ya reintentó; ->retryAfter()
5xx ServerException El SDK ya reintentó
red / TLS / timeout TransportException

Nunca se reintenta automáticamente un 4xx que no sea 429. Los 429 y 5xx se reintentan con espera exponencial y jitter, 3 veces por defecto:

new ClientOptions(maxRetries: 5, retryBaseDelay: 0.5, retryMaxDelay: 8.0);

Cada reintento se vuelve a firmar con un timestamp fresco, para que la espera no invalide la firma.

Los tres 401 que el SDK hace imposibles

  1. Firmar un cuerpo reserializado. Http\Request guarda el cuerpo como cadena ya serializada. Se hashea esa cadena y se transmite esa misma cadena; el cliente HTTP recibe un string, nunca un array que pudiera volver a serializar.
  2. Cuerpo vacío en GET/DELETE. Request::get() y Request::delete() no aceptan payload: su cuerpo es '' y se hashea la cadena vacía (e3b0c442…b855), no [] ni {}.
  3. Reloj desincronizado. Un 401 con Expired service signature. llega como ClockSkewException, con el desfase estimado en ->skewSeconds, no como un error genérico de credenciales.

Si aun así necesitas depurar una firma:

$firma = $platform->debugSignature(Request::get('/api/v1/conversations')->withTenant('42', '7'));

echo $firma->canonical;   // el string canónico exacto que se firmó (no contiene el secreto)

Cómo se firma (referencia)

El string canónico une siete partes con puntos, en este orden:

timestamp . METHOD . /path . sha256(raw_body) . platform_slug . account_id . user_id

HMAC-SHA256(canonical, platform_secret) en hex minúsculas, con el prefijo sha256=. Cabeceras emitidas:

X-Chat-Platform:   codi
X-Chat-Timestamp:  1787440000
X-Chat-Account-Id: 42
X-Chat-User-Id:    7
X-Chat-Signature:  sha256=4f53cda...

El servidor todavía acepta los alias heredados X-Homlity-*, pero el SDK emite únicamente las X-Chat-*. La tolerancia de reloj es de 300 segundos. La query string no entra en la firma.

Referencia de la API

PlatformClient — solo backend, firma HMAC

$platform->accessTokens()->issue($accountId, $name, $abilities, $expiresInMinutes, $userId);
$platform->accessTokens()->list($accountId);
$platform->accessTokens()->revoke($accountId, $tokenUuid);

$platform->webhooks()->get($scope);
$platform->webhooks()->register($url, $events, $scope, $accountId);
$platform->webhooks()->disable($scope);
$platform->webhooks()->test();                      // diagnóstico
$platform->webhooks()->deliveries($status);
$platform->webhooks()->allDeliveries($status);      // iteración perezosa
$platform->webhooks()->retryDelivery($deliveryUuid);

$platform->accountClient($token);                   // cliente de cuenta para tareas de servidor
$platform->actingAsAccount($accountId, $abilities); // emite y construye en un paso

AccountClient — token Bearer

$chat->conversations()->list($status, $mode, $perPage, $page);   // una página
$chat->conversations()->all($status, $mode, $perPage);           // iteración perezosa
$chat->conversations()->get($uuid);
$chat->conversations()->messages($uuid, $perPage, $page);
$chat->conversations()->allMessages($uuid);
$chat->conversations()->sendMessage($uuid, $text, $clientMessageId, $takeOver);
$chat->conversations()->takeover($uuid);
$chat->conversations()->returnToAi($uuid);
$chat->conversations()->close($uuid);

$chat->realtime()->events($afterId);                // sondeo
$chat->realtime()->stream($callback, $afterId, $maxReconnects);

$chat->ai()->config();
$chat->ai()->updateConfig(AiConfigUpdate::make()->enabled(true)->model('...'));
$chat->ai()->knowledge();
$chat->ai()->addKnowledge($type, $name, $content, $source, $status);
$chat->ai()->updateKnowledge($uuid, $fields);
$chat->ai()->deleteKnowledge($uuid);

$chat->integrations()->list();
$chat->integrations()->get($uuid);
$chat->integrations()->connectWhatsapp($payload);
$chat->integrations()->completeWhatsapp($state, $code, $metaBusinessId, $wabaId, $phoneNumberId);
$chat->integrations()->delete($uuid);

$chat->templates()->list($integrationId, $status);
$chat->templates()->all($integrationId, $status);
$chat->templates()->sync($integrationUuid);

$chat->campaigns()->list();
$chat->campaigns()->all();
$chat->campaigns()->get($uuid);
$chat->campaigns()->create($name, $integrationId, $templateId, $scheduledAt, $metadata);
$chat->campaigns()->update($uuid, $fields);
$chat->campaigns()->addRecipients($uuid, $recipients);   // trocea de 500 en 500
$chat->campaigns()->cancel($uuid);
$chat->campaigns()->send($uuid);

Configuración de la IA

Cada inmobiliaria pone su propia suscripción de IA. La credencial se envía una vez, se guarda cifrada y no vuelve nunca: la respuesta solo trae credential_last_four y credential_status.

use Homlity\Chat\Account\AiConfigUpdate;
use Homlity\Chat\Enum\AiTone;
use Homlity\Chat\Enum\AiTool;

$chat->ai()->updateConfig(
    AiConfigUpdate::make()
        ->name('Asistente de Inmobiliaria Norte')
        ->enabled(true)
        ->model('claude-sonnet-5')
        ->apiKey($claveDeLaInmobiliaria)          // solo esta vez
        ->systemInstructions('Responde en español, tutea, no inventes precios.')
        ->maxResponseLength(1200)                 // 100–4096, validado en el cliente
        ->maxToolCallsPerTurn(3)                  // 0–10
        ->autoHandoffEnabled(true)
        ->tone(AiTone::FRIENDLY)
        ->allowedTools([AiTool::SEARCH_PROPERTIES, AiTool::CREATE_LEAD, AiTool::REQUEST_HUMAN_AGENT])
);

// Borrar la credencial y desactivar el asistente:
$chat->ai()->updateConfig(AiConfigUpdate::make()->removeApiKey());

El objeto es inmutable y solo envía los campos que tocas: un PATCH de dos campos no borra el resto. Los rangos se validan antes de salir a la red.

Campañas y consentimiento

use Homlity\Chat\Account\Recipient;

$chat->campaigns()->addRecipients($campanaUuid, [
    Recipient::optedIn(
        phone: '573001112233',
        consentedAt: '2026-08-01T10:00:00-05:00',
        consentSource: 'web_form',
        displayName: 'María',
        templateComponents: [['type' => 'body', 'parameters' => [['type' => 'text', 'text' => 'María']]]],
    ),
    // ... tantos como quieras: el SDK trocea en lotes de 500
]);

opted_in es obligatorio y, cuando es true, consented_at y consent_source también. El SDK lo comprueba antes de enviar, para que el fallo aparezca en tu código y no como un 422 a mitad de un lote.

Paginación

Los listados devuelven paginación estilo Laravel. perPage admite hasta 100.

$pagina = $chat->conversations()->list(perPage: 50);
$pagina->data; $pagina->currentPage; $pagina->lastPage; $pagina->total;
$pagina->hasMorePages();

// Iteración perezosa sobre elementos, atravesando páginas:
foreach ($chat->conversations()->all() as $conversacion) { /* ... */ }

// O página a página:
foreach ($chat->conversations()->all()->pages() as $pagina) { /* ... */ }

Tiempo real (SSE)

$chat->realtime()->stream(function (array $evento): bool {
    // procesa
    return true;    // devolver false corta la escucha
}, afterId: $ultimoIdConocido);

El stream cierra a los ~15 segundos por diseño: reconectar es lo normal, no un error. El helper conserva el último id recibido y reconecta con after_id, así que no se pierden eventos entre reconexiones. Para un sondeo simple está ->events($afterId).

Enumeraciones

Están modeladas como tipos abiertos: constantes con los valores conocidos y un isKnown(). Si el servicio añade un valor nuevo, el SDK lo entrega como cadena en vez de lanzar una excepción.

use Homlity\Chat\Enum\ConversationStatus;   // open, pending, resolved, closed, spam
use Homlity\Chat\Enum\ConversationMode;     // ai, waiting_human, human, paused
use Homlity\Chat\Enum\MessageDirection;     // inbound, outbound
use Homlity\Chat\Enum\MessageSenderType;    // customer, ai, human, system
use Homlity\Chat\Enum\MessageType;          // text, image, audio, video, document, ...
use Homlity\Chat\Enum\MessageStatus;        // received, pending, sending, sent, ...
use Homlity\Chat\Enum\Ability;              // permisos de token
use Homlity\Chat\Enum\WebhookEventType;
use Homlity\Chat\Enum\AiTool;
use Homlity\Chat\Enum\AiTone;
use Homlity\Chat\Enum\KnowledgeType;

ConversationStatus::isKnown($conversacion['status']);

Configuración

use Homlity\Chat\Http\ClientOptions;

$options = new ClientOptions(
    connectTimeout: 5.0,          // segundos
    timeout: 15.0,
    maxRetries: 3,
    retryBaseDelay: 0.5,
    retryMaxDelay: 8.0,
    httpClient: null,             // cualquier HttpClientInterface, p. ej. Psr18HttpClient
    userAgentSuffix: 'codi-web/2.4',
);

$platform = new PlatformClient('codi', $secreto, options: $options);

Con un cliente PSR-18 propio (Guzzle, Symfony HttpClient):

use Homlity\Chat\Http\Psr18HttpClient;

$options = new ClientOptions(
    httpClient: new Psr18HttpClient($guzzle, $requestFactory, $streamFactory),
);

TLS. La verificación del certificado está siempre activa y no hay ninguna opción para desactivarla. La URL base debe ser https, salvo contra un host local de desarrollo.

Secretos en logs. Ni tokens, ni secretos, ni firmas, ni claves de IA aparecen en mensajes de error, en var_dump(), en print_r() ni al volcar los objetos del SDK: todo pasa por Homlity\Chat\Support\Redact.

Tests

La capa HTTP es inyectable, así que se puede probar toda la integración sin red:

use Homlity\Chat\Http\ClientOptions;
use Homlity\Chat\Http\HttpClientInterface;
use Homlity\Chat\Http\Response;

final class ClienteFalso implements HttpClientInterface
{
    public function send(string $method, string $url, array $headers, string $body): Response
    {
        return new Response(200, ['content-type' => 'application/json'], '{"data":[]}');
    }
}

$chat = new AccountClient('hchat_de_prueba', options: new ClientOptions(httpClient: new ClienteFalso()));

La suite del SDK:

composer install
composer test

Cubre la firma canónica contra vectores conocidos, la verificación de webhooks (válida, firma alterada, cuerpo alterado, cuerpo reserializado, timestamp viejo), la idempotencia de los envíos, los reintentos (429, 5xx y fallos de red; nunca otros 4xx), la traducción de errores, la paginación perezosa, la reconexión del stream y el enmascarado de secretos.

Ejemplos

Archivo Qué muestra
01_backend_emitir_token.php Emitir un token desde el backend
02_sesion_asesor.php Listar, tomar el control, responder, escuchar
03_registrar_webhook.php Registrar el webhook y diagnosticarlo
04_receptor_webhook.php Receptor completo, ejecutable, con verificación
05_laravel.php Todo lo anterior en Laravel
06_extremo_a_extremo.php El recorrido completo, ejecutable

El recorrido completo corre incluso sin credenciales, contra un servicio simulado:

php examples/06_extremo_a_extremo.php

Licencia

MIT.