everesthome/sendify

PHP client and Laravel integration for the Sendify WhatsApp service

Maintainers

Package info

github.com/everesthome/sendify-php

pkg:composer/everesthome/sendify

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.1 2026-08-16 23:20 UTC

This package is not auto-updated.

Last update: 2026-08-16 23:22:53 UTC


README

Packagist Tests Descargas Licencia

Paquete: https://packagist.org/packages/everesthome/sendify

Cliente del servicio Sendify para mandar WhatsApp desde cualquier aplicación PHP. En Laravel se instala, se ponen tres claves en el .env y ya:

Sendify::TextMessageTo('5215551234567', 'Hola desde Laravel');

El núcleo no depende de ningún framework: sólo cURL y JSON.

Instalación

composer require everesthome/sendify

En Laravel el service provider y la fachada Sendify se registran solos. La configuración es opcional:

php artisan vendor:publish --tag=sendify-config

Configuración

En el .env de tu aplicación:

SENDIFY_URL="https://sendify.miempresa.mx"
SENDIFY_CLIENT="snd_live_xxxxxxxxxxxxxxxx"
SENDIFY_INSTANCE="ventas"
Variable Qué es
SENDIFY_URL Base del servidor Sendify de esa empresa (cada empresa puede tener el suyo)
SENDIFY_CLIENT API key de la instancia; viaja en el header X-API-Key
SENDIFY_INSTANCE ID numérico o nombre de la instancia de WhatsApp

También se aceptan sin guion bajo (SENDIFYURL, SENDIFYCLIENT, SENDIFYINSTANCE).

Opcionales: SENDIFY_TIMEOUT (30), SENDIFY_CONNECT_TIMEOUT (10), SENDIFY_RETRIES (1), SENDIFY_VERIFY_SSL (true), SENDIFY_CONNECTION (default).

Uso

Los nombres de método en PHP no distinguen mayúsculas, así que Sendify::TextMessageTo() y Sendify::textMessageTo() son lo mismo. Elige el estilo que prefieras.

use EverestHome\Sendify\Laravel\Facades\Sendify;

Sendify::TextMessageTo('+52 55 1234 5678', 'Tu pedido va en camino');
Sendify::ImageMessageTo('5215551234567', 'https://cdn.miempresa.mx/promo.jpg', 'Promo del mes');
Sendify::DocumentMessageTo('5215551234567', storage_path('app/facturas/F-1023.pdf'), 'F-1023.pdf', 'Tu factura');
Sendify::LocationMessageTo('5215551234567', 19.4326, -99.1332, 'Sucursal Centro');
Sendify::ContactMessageTo('5215551234567', 'Soporte', '5215557654321');
Sendify::PollMessageTo('5215551234567', '¿Qué horario prefieres?', ['Mañana', 'Tarde']);
Sendify::TemplateMessageTo('5215551234567', 'bienvenida', ['nombre' => 'Jovan']);

El número se limpia solo: +52 55 1234 5678, 5215551234567 y 5215551234567@c.us llegan igual. Los JID de grupo (...@g.us) se respetan tal cual.

Estilo encadenado si mandas varias cosas al mismo chat:

Sendify::to('5215551234567')->text('Hola');
Sendify::to('5215551234567')->voiceNote(storage_path('app/audios/nota.ogg'));

Medios

Cualquier método de medios acepta una URL pública, una ruta local (se lee y se manda en base64 con su mimetype), un data: URI o base64 crudo (aquí sí hay que pasar el mimetype).

Dos reglas del servicio:

  • 25 MB máximo por archivo en base64. El cliente lo revisa antes de subir nada y lanza ValidationException para no gastar el viaje.
  • La URL la descarga el servidor, así que tiene que resolver a una dirección pública: localhost, LAN privada, link-local (incluido 169.254.169.254) y CGNAT se rechazan con 400. Si tu servidor de medios vive en la misma red privada que Sendify, el servicio tiene que correr con ALLOW_PRIVATE_MEDIA_URLS=true.
Sendify::VideoMessageTo('5215551234567', public_path('videos/demo.mp4'), 'Demo');
Sendify::AudioMessageTo('5215551234567', 'https://cdn.miempresa.mx/nota.ogg', ptt: true);
Sendify::StickerMessageTo('5215551234567', 'https://cdn.miempresa.mx/sticker.webp');

Respuesta

Todos los envíos devuelven un Response que se puede leer como array o con atajos:

$response = Sendify::TextMessageTo('5215551234567', 'Hola');

$response->messageId();      // 'BAE5...'
$response->successful();     // true
$response->data();           // nodo data del JSON
$response['data']['status']; // acceso tipo array
$response->json('data.messageId');

Envío masivo

$batch = Sendify::BulkMessages([
    '5215551234567' => 'Hola Ana',
    '5215559876543' => 'Hola Luis',
]);

Sendify::batch($batch->json('id'));   // avance
Sendify::cancelBatch($batch->json('id'));

Acciones sobre mensajes

Sendify::replyTo('5215551234567', $messageId, 'Claro que sí');
Sendify::forwardTo('5215559876543', $messageId);
Sendify::react($messageId, '👍');
Sendify::editMessage($messageId, 'Texto corregido');
Sendify::deleteMessage($messageId);
Sendify::pinMessage($messageId, 86400);
Sendify::starMessage($messageId);

Sendify::messages(['chatId' => '5215551234567', 'limit' => 50]);
Sendify::messageMedia($messageId)->body(); // binario del adjunto

Estado de la instancia

Sendify::Status() es el diagnóstico completo y nunca lanza excepciones: si el servidor está caído, la instancia no existe o la cuenta está suspendida, eso mismo es el estado.

$estado = Sendify::Status();

$estado->state;            // EverestHome\Sendify\Enums\InstanceState::Suspended
$estado->value();          // 'suspended'
$estado->message;          // 'Cuenta suspendida: instancia desactivada o API key revocada'
$estado->canSend();        // false
$estado->accountProblem(); // true
$estado->httpStatus;       // 401
state Qué pasó canSend()
connected Conectada a WhatsApp
connecting Levantando el socket no
qr_ready Hay un QR esperando a que lo escaneen no
hibernated Dormida para no gastar RAM; el envío la despierta sola
disconnected Vinculada pero sin conexión no
unlinked Nunca se vinculó el teléfono o se cerró la sesión: hay que escanear no
instance_not_found La instancia no existe o la API key no pertenece a ella (403/404) no
suspended Instancia desactivada o API key revocada (401) no
key_expired API key expirada: renovación o cobro pendiente (401) no
missing_credentials No se mandó API key (401) no
ip_not_allowed La IP de este servidor no está en la lista blanca de la key (403) no
insufficient_role La API key existe pero su rol no alcanza (403) no
rate_limited Se topó el límite de peticiones por minuto (429) no
unreachable El servidor Sendify no contestó: caído, DNS, TLS o timeout no
server_error El servidor Sendify respondió 5xx no
use EverestHome\Sendify\Enums\InstanceState;

$estado = Sendify::Status();

if ($estado->canSend()) {
    Sendify::TextMessageTo($telefono, $texto);
} elseif ($estado->accountProblem()) {
    // suspendida, key expirada, IP bloqueada o instancia inexistente
    Notification::route('mail', 'admin@miempresa.mx')->notify(new SendifyCaido($estado->message));
} elseif ($estado->is(InstanceState::QrReady, InstanceState::Unlinked)) {
    // alguien tiene que escanear el QR: Sendify::qr()
}

Otros atajos: ->connected(), ->hibernated(), ->suspended(), ->needsAttention(), ->needsStart(), ->hasCredentials(), ->hibernationReason(), ->instanceName(), ->business(), ->lastConnectionAt(), ->lastActiveAt(), ->toArray(). También se serializa a JSON y se lee como array ($estado['state']).

Para el avance de una vinculación: ->connecting(), ->reconnecting() (ya hay un reintento programado, no está ociosa), ->reconnectAttempts(), ->qrAttempt(), ->maxQrCycles() y ->qrExpiresAt().

hibernatedAt y hibernationReason sólo vienen mientras la instancia está realmente dormida. Una que nunca se vinculó, o cuyo logout() borró la sesión, reporta disconnected sin credenciales, y el cliente la traduce a unlinked.

Para distinguir "servidor caído" de "problema de esta cuenta" está Sendify::serverReachable() (pega a /health, sin API key de por medio). Sendify::healthLive() es la sonda de vida: contesta 200 mientras el proceso viva aunque la base de datos esté caída — health() sí revisa la base y da 503 si no la alcanza. Y si prefieres el JSON crudo con excepciones, Sendify::statusResponse().

Instancia

Sendify::connected();   // bool
Sendify::qr();          // { qr, qrExpiresAt, qrAttempt } — cada QR vive 60 s
Sendify::start();       // abre socket o emite un QR nuevo
Sendify::stop();        // hiberna, conservando la sesión
Sendify::hibernate();
Sendify::wake();
Sendify::logout();      // desvincula el teléfono y BORRA la sesión
Sendify::forceKill();
Sendify::pairingCode('5215551234567');
Sendify::config();
Sendify::updateConfig(['idleTimeoutMs' => 900000, 'wakeTimeoutMs' => 8000]);
Sendify::stats();

Roles de la API key: read-only lee estado, historial y stats; operator además envía, actúa sobre mensajes y puede wake(); admin además maneja ciclo de vida, config, webhooks, plantillas, automatizaciones y perfil. Leer el QR no arranca nada: si la instancia duerme, qr() responde qr: null y needsStart: true hasta que llames a start().

Los IDs de grupo (...@g.us) salen de GET /api/management/instances/:id/groups, que va con sesión del panel y no con API key, así que ese endpoint no está en este cliente.

Webhooks, plantillas, automatizaciones y perfil

$webhook = Sendify::webhooks()->create('CRM', 'https://crm.miempresa.mx/sendify', [
    'message.received', 'message.sent', 'message.status', 'connection.updated',
]);

$secret = $webhook->json('secret'); // se muestra una sola vez

Sendify::templates()->create('bienvenida', 'Hola {{nombre}}, gracias por escribir.');

Sendify::automations()->create(
    name: 'Horario',
    triggerType: 'message.received',
    conditions: ['contains' => 'horario'],
    actionType: 'send_text',
    actionPayload: ['text' => 'Atendemos de 9:00 a 18:00.'],
);

Sendify::profile()->name('Soporte Everest Home');
Sendify::statuses()->text('Estamos en línea', backgroundColor: '#25D366');

Eventos disponibles (EverestHome\Sendify\Resources\Webhooks::EVENTS): message.received, message.sent, message.status, connection.updated, call.received. Usa ['*'] para recibir todo. Las entregas fallidas se reintentan 5 veces con retroceso exponencial (2^intentos × 15 s) y quedan en Sendify::webhooks()->deliveries().

Para validar la firma de una entrega en tu controlador de Laravel:

use EverestHome\Sendify\Resources\Webhooks;

if (! Webhooks::verifySignature($request->getContent(), $request->header('X-Sendify-Signature', ''), config('services.sendify.secret'))) {
    abort(401);
}

Varias instancias o varios servidores

Cada empresa puede tener su propio servidor Sendify. Agrega conexiones en config/sendify.php:

'connections' => [
    'default' => [
        'url' => env('SENDIFY_URL'),
        'client' => env('SENDIFY_CLIENT'),
        'instance' => env('SENDIFY_INSTANCE'),
    ],
    'cobranza' => [
        'url' => env('SENDIFY_COBRANZA_URL'),
        'client' => env('SENDIFY_COBRANZA_CLIENT'),
        'instance' => env('SENDIFY_COBRANZA_INSTANCE'),
    ],
],
Sendify::connection('cobranza')->TextMessageTo('5215551234567', 'Recordatorio de pago');

Misma API key, otra instancia:

Sendify::instance('soporte')->TextMessageTo('5215551234567', 'Hola');

Credenciales que viven en la base de datos (multi-tenant):

use EverestHome\Sendify\SendifyManager;

$sendify = app(SendifyManager::class)->build([
    'url' => $tenant->sendify_url,
    'client' => $tenant->sendify_key,
    'instance' => $tenant->sendify_instance,
]);

$sendify->TextMessageTo($cliente->telefono, 'Hola');

Errores

Toda respuesta fuera del rango 2xx lanza una excepción que hereda de SendifyException:

Excepción Cuándo
AuthenticationException 401/403: API key inválida, expirada, IP no permitida
ValidationException 400/422: faltan campos o el número no es válido
NotFoundException 404: plantilla, lote o mensaje inexistente
InstanceNotConnectedException 409: la instancia no está conectada a WhatsApp
RateLimitException 429: se topó el límite de peticiones de la key
InstanceAsleepException 503: hibernando, no despertó a tiempo (retryAfter())
ConnectionException No hubo respuesta: DNS, TLS, timeout

Las respuestas 503 reintentables y las 429 se reintentan solas según SENDIFY_RETRIES, esperando lo que indique el header Retry-After.

En un 422 el servicio manda los errores por campo; $e->errors() te los da tal cual:

[['field' => 'chatId', 'rule' => 'required', 'message' => 'El campo chatId es obligatorio']]
use EverestHome\Sendify\Exceptions\InstanceAsleepException;
use EverestHome\Sendify\Exceptions\SendifyException;

try {
    Sendify::TextMessageTo($telefono, $texto);
} catch (InstanceAsleepException $e) {
    SendWhatsApp::dispatch($telefono, $texto)->delay(now()->addSeconds($e->retryAfter()));
} catch (SendifyException $e) {
    report($e);
}

Como todo lanza excepciones, en una cola de Laravel el reintento del job sale gratis.

Sin Laravel

use EverestHome\Sendify\Sendify;

$sendify = Sendify::make('https://sendify.miempresa.mx', 'snd_live_xxx', 'ventas');

$sendify->textMessageTo('5215551234567', 'Hola desde PHP puro');

Para usar otro cliente HTTP (Guzzle, el de Laravel, etc.) implementa EverestHome\Sendify\Http\ClientInterface y pásalo con $sendify->withHttpClient($cliente) o enlázalo en el contenedor de Laravel.

Pruebas

composer install
composer test

Las pruebas usan un cliente HTTP falso; no tocan la red ni requieren un Sendify corriendo.

Licencia

MIT. Ver LICENSE.md.