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.
Requires
- php: >=8.1
- ext-curl: *
- ext-hash: *
- ext-json: *
- ext-openssl: *
Requires (Dev)
- phpunit/phpunit: ^10.5
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/simple-cache: ^2.0 || ^3.0
Suggests
- illuminate/http: Para usar el middleware de Laravel incluido en Homlity\Chat\Webhook\Laravel.
- psr/http-client: Para inyectar un cliente HTTP PSR-18 (Guzzle, Symfony HttpClient) en lugar del transporte cURL incluido.
- psr/http-server-middleware: Para usar el middleware PSR-15 incluido en Homlity\Chat\Webhook\Psr15 (Slim, Mezzio).
- psr/simple-cache: Para deduplicar entregas de webhook en un almacén compartido (Redis, Memcached) entre varios procesos.
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 | Sí | 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 |
Sí | El SDK ya reintentó; ->retryAfter() |
| 5xx | ServerException |
Sí | El SDK ya reintentó |
| red / TLS / timeout | TransportException |
Sí |
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
- Firmar un cuerpo reserializado.
Http\Requestguarda el cuerpo como cadena ya serializada. Se hashea esa cadena y se transmite esa misma cadena; el cliente HTTP recibe unstring, nunca un array que pudiera volver a serializar. - Cuerpo vacío en GET/DELETE.
Request::get()yRequest::delete()no aceptan payload: su cuerpo es''y se hashea la cadena vacía (e3b0c442…b855), no[]ni{}. - Reloj desincronizado. Un 401 con
Expired service signature.llega comoClockSkewException, 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.