

Facturación electrónica en PHP para Ecuador (SRI, con ejemplos)
Cómo emitir facturas electrónicas en PHP integrando con el SRI Ecuador. Ejemplos con cURL y Guzzle, webhooks, errores y repo facturación electrónica PHP GitHub.
PHP sigue moviendo gran parte del comercio digital y los sistemas ERP/POS en Ecuador. Si tu app está en PHP y necesita emitir facturas electrónicas autorizadas por el SRI, la API REST de Factuplan se integra en pocas horas con cURL nativo o con Guzzle, sin tocar XMLs ni firmas XAdES-BES.
En este tutorial vas a ver cada paso: pre-requisitos, configuración, emisión de la primera factura con cURL y con Guzzle, manejo de errores, webhooks y patrones para WordPress/Laravel.
Pre-requisitos para emitir factura electrónica PHP
1. PHP 8.1+
php --version # debe ser >= 8.1Versiones anteriores funcionan con ajustes menores, pero PHP 8.1+ te da mejor type safety y readonly properties.
2. Extensiones requeridas
ext-curl(estándar en casi todas las instalaciones).ext-json(estándar).ext-mbstring(recomendado).
Verifica:
php -m | grep -i -E "curl|json|mbstring"3. Composer (opcional pero recomendado)
Si vas a usar Guzzle:
composer require guzzlehttp/guzzle4. Cuenta + API key en Factuplan
- Crea cuenta en app.factuplan.com.ec. Plan Free con 25 facturas/mes sin costo.
- Developer → Create API key.
- Copia la clave (
ak_test_*para pruebas,ak_live_*para producción). - Guárdala en variables de entorno o en un archivo
.env.
FACTUPLAN_API_KEY=ak_test_xxxxxxxxxxxxxxxxxxFACTUPLAN_RUC=09501944070015. RUC + firma electrónica
Para producción necesitas RUC activo y certificado P12 cargado en tu workspace de Factuplan. En pruebas, basta con tu cuenta y la API key ak_test_*. Guía para obtener firma electrónica SRI.
Opción A — Llamadas con cURL nativo
Si quieres minimizar dependencias o trabajas en un entorno donde Composer no está disponible (algunos hostings shared), usa cURL directamente.
Cliente base
<?phpclass FactuplanClient { private string $baseUrl = 'https://api.factuplan.com.ec'; private string $apiKey; private string $taxpayerRuc;
public function __construct(string $apiKey, string $taxpayerRuc) { $this->apiKey = $apiKey; $this->taxpayerRuc = $taxpayerRuc; }
public function request(string $method, string $path, ?array $body = null): array { $ch = curl_init("{$this->baseUrl}{$path}");
$headers = [ "X-API-Key: {$this->apiKey}", "x-taxpayer-ruc: {$this->taxpayerRuc}", 'Accept: application/json', 'Content-Type: application/json', ];
curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 60, ]);
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE)); }
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch);
if ($response === false) { throw new RuntimeException("cURL error: {$error}"); }
$decoded = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
if ($httpCode >= 400) { throw new FactuplanApiException( $decoded['message'] ?? 'Error desconocido', $httpCode, $decoded['code'] ?? null, $decoded['details'] ?? null, ); }
return $decoded; }}
class FactuplanApiException extends RuntimeException { public function __construct( string $message, public readonly int $statusCode, public readonly ?string $apiCode = null, public readonly ?array $details = null, ) { parent::__construct($message, $statusCode); }}Emitir factura con cURL
<?phprequire_once 'factuplan-client.php';
$client = new FactuplanClient( apiKey: getenv('FACTUPLAN_API_KEY'), taxpayerRuc: getenv('FACTUPLAN_RUC'),);
$payload = [ 'customer' => [ 'identificationType' => 'RUC', 'identification' => '0993378150001', 'legalName' => 'Cliente Demo S.A.', 'email' => 'facturas@cliente-demo.ec', 'saveToContacts' => true, ], 'items' => [ [ 'code' => 'SERV-001', 'description' => 'Desarrollo web mensual', 'quantity' => 1, 'unitPrice' => 500.00, 'discount' => 0, 'taxType' => 'IVA_RATE', 'tax' => 15, ], ], 'payments' => [ [ 'method' => '20', // Transferencia bancaria (catálogo SRI) 'amount' => 575.00, // 500 + IVA 15% ], ], 'sendEmail' => true,];
try { $response = $client->request('POST', '/v1/developer/invoices', $payload); $factura = $response['data'];
echo "✅ Factura emitida\n"; echo "Clave de acceso: {$factura['accessKey']}\n"; echo "RIDE PDF: {$factura['pdfUrl']}\n"; echo "Estado: {$factura['status']}\n";} catch (FactuplanApiException $e) { echo "❌ Error: {$e->getMessage()} (HTTP {$e->statusCode})\n"; if ($e->apiCode) { echo "Código API: {$e->apiCode}\n"; } if ($e->details) { echo "Detalles: " . json_encode($e->details, JSON_UNESCAPED_UNICODE) . "\n"; }}Ejecuta:
FACTUPLAN_API_KEY=ak_test_xxx FACTUPLAN_RUC=0950194407001 php emitir.phpOpción B — Llamadas con Guzzle
Si ya usas Guzzle en tu proyecto (Laravel, Symfony, Slim), úsalo: maneja reintentos, middlewares, async y mejor ergonomía.
Instalación
composer require guzzlehttp/guzzleCliente con Guzzle
<?phpuse GuzzleHttp\Client;use GuzzleHttp\Exception\ClientException;use GuzzleHttp\Exception\ServerException;use GuzzleHttp\Exception\ConnectException;
class FactuplanGuzzleClient { private Client $http;
public function __construct(string $apiKey, string $taxpayerRuc) { $this->http = new Client([ 'base_uri' => 'https://api.factuplan.com.ec', 'headers' => [ 'X-API-Key' => $apiKey, 'x-taxpayer-ruc' => $taxpayerRuc, 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], 'timeout' => 60, 'connect_timeout' => 10, ]); }
public function emitirFactura(array $payload): array { try { $response = $this->http->post('/v1/developer/invoices', [ 'json' => $payload, ]); $data = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); return $data['data']; } catch (ClientException $e) { $body = json_decode($e->getResponse()->getBody()->getContents(), true); throw new FactuplanApiException( $body['message'] ?? 'Error de validación', $e->getCode(), $body['code'] ?? null, $body['details'] ?? null, ); } catch (ServerException $e) { throw new RuntimeException("Factuplan API caída (HTTP {$e->getCode()})", 0, $e); } catch (ConnectException $e) { throw new RuntimeException("Sin conexión a Factuplan", 0, $e); } }
public function obtenerFactura(string $id): array { $response = $this->http->get("/v1/developer/invoices/{$id}"); $data = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); return $data['data']; }
public function listarFacturas(int $page = 1, int $limit = 20): array { $response = $this->http->get('/v1/developer/invoices', [ 'query' => ['page' => $page, 'limit' => $limit], ]); return json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); }}Uso con Guzzle
<?phprequire 'vendor/autoload.php';require 'FactuplanGuzzleClient.php';
$client = new FactuplanGuzzleClient( apiKey: getenv('FACTUPLAN_API_KEY'), taxpayerRuc: getenv('FACTUPLAN_RUC'),);
$factura = $client->emitirFactura([ 'customer' => [ 'identificationType' => 'RUC', 'identification' => '0993378150001', 'legalName' => 'Cliente Demo S.A.', 'email' => 'facturas@cliente-demo.ec', ], 'items' => [ [ 'description' => 'Servicio mensual', 'quantity' => 1, 'unitPrice' => 500.00, 'taxType' => 'IVA_RATE', 'tax' => 15, ], ], 'sendEmail' => true,]);
echo "Clave: {$factura['accessKey']}\n";echo "PDF: {$factura['pdfUrl']}\n";Manejo de respuestas y errores
La API REST de Factuplan devuelve respuestas con el sobre estándar:
{ "data": { /* recurso o lista */ }, "meta": { "requestId": "req_1a2b3c", "timestamp": "2026-05-27T12:34:56.789Z" }}Y los errores con:
{ "statusCode": 422, "message": "Customer identification is invalid", "code": "INVOICE_4002", "details": [ "identification must be a 13-digit RUC" ]}Tabla de códigos HTTP
| Código | Significado | Acción en PHP |
|---|---|---|
200 / 201 | Éxito | Procesar respuesta |
400 | Validación / body mal formado | Corregir payload |
401 | API key inválida | Revisar .env |
403 | Permisos insuficientes / plan incompatible | Verificar plan en dashboard |
404 | Recurso no encontrado | Validar IDs |
422 | Rechazo por reglas SRI o de negocio | Inspeccionar details |
429 | Rate limit / cuota mensual | Esperar Retry-After o subir de plan |
500-503 | Error temporal del servidor | Reintentar con backoff |
Reintentos con backoff (cURL)
PHP no tiene reintentos automáticos como el SDK Node. Implementa así:
function withBackoff(callable $fn, int $maxAttempts = 5): mixed { $attempt = 0; while ($attempt < $maxAttempts) { try { return $fn(); } catch (FactuplanApiException $e) { if (!in_array($e->statusCode, [429, 500, 502, 503, 504], true)) { throw $e; } $delay = min(1000000 * (2 ** $attempt), 30000000); // microsegundos usleep($delay); $attempt++; } } throw new RuntimeException("Max reintentos excedidos");}
// Uso$factura = withBackoff(fn () => $client->request('POST', '/v1/developer/invoices', $payload));Reintentos con Guzzle middleware
Guzzle tiene GuzzleHttp\RetryMiddleware:
use GuzzleHttp\HandlerStack;use GuzzleHttp\Middleware;use GuzzleHttp\Psr7\Response;use Psr\Http\Message\RequestInterface;
$stack = HandlerStack::create();$stack->push(Middleware::retry( function ($retries, RequestInterface $req, ?Response $res, ?Throwable $err) { if ($retries >= 5) return false; if ($err instanceof \GuzzleHttp\Exception\ConnectException) return true; if ($res && in_array($res->getStatusCode(), [429, 500, 502, 503, 504], true)) return true; return false; }, fn ($retries) => 1000 * (2 ** $retries) // ms));
$client = new Client([ 'base_uri' => 'https://api.factuplan.com.ec', 'handler' => $stack, // ...]);Webhooks en PHP
Para recibir eventos de Factuplan (autorización del SRI, rechazos, etc.) necesitas:
- Endpoint HTTPS público (no HTTP).
- Verificar firma HMAC con tu webhook secret.
- Responder rápido (< 5 segundos) con
200 OK.
Implementación con PHP puro
<?php// IMPORTANTE: leer el body RAW antes de cualquier parseo$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_FACTUPLAN_SIGNATURE'] ?? '';$secret = getenv('FACTUPLAN_WEBHOOK_SECRET');
// Verificar firma HMAC SHA-256$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature)) { http_response_code(400); echo 'Invalid signature'; exit;}
// Procesar evento$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
switch ($event['type']) { case 'invoice.authorized': $accessKey = $event['data']['accessKey']; error_log("Factura autorizada: {$accessKey}"); // Tu lógica: marcar pedido como facturado, notificar al cliente, etc. break;
case 'invoice.rejected': $sriError = $event['data']['sriError'] ?? 'sin detalle'; error_log("Factura rechazada por SRI: {$sriError}"); // Tu lógica: avisar al equipo de operaciones, intentar corrección. break;
case 'credit_note.authorized': // ... break;}
http_response_code(200);echo json_encode(['received' => true]);En Laravel (con Route + Controller)
Route::post('/webhooks/factuplan', [FactuplanWebhookController::class, 'handle']) ->withoutMiddleware(['web', 'csrf']); // webhooks no necesitan sesión ni CSRF
// app/Http/Controllers/FactuplanWebhookController.phpclass FactuplanWebhookController extends Controller{ public function handle(Request $request) { $signature = $request->header('x-factuplan-signature'); $secret = config('services.factuplan.webhook_secret'); $rawBody = $request->getContent();
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature ?? '')) { return response('Invalid signature', 400); }
$event = $request->json()->all();
match ($event['type']) { 'invoice.authorized' => $this->handleAuthorized($event['data']), 'invoice.rejected' => $this->handleRejected($event['data']), default => null, };
return response()->json(['received' => true]); }}Integración con WordPress / WooCommerce
Si vendes desde WooCommerce y quieres emitir factura electrónica automáticamente al completar un pedido, usa el hook woocommerce_order_status_completed:
add_action('woocommerce_order_status_completed', function ($order_id) { $order = wc_get_order($order_id); $cliente = [ 'identificationType' => 'CEDULA', // ajustar según campos de WC 'identification' => $order->get_meta('_cedula'), 'legalName' => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(), 'email' => $order->get_billing_email(), ];
$items = []; foreach ($order->get_items() as $item) { $items[] = [ 'code' => $item->get_product()->get_sku() ?: (string) $item->get_product_id(), 'description' => $item->get_name(), 'quantity' => $item->get_quantity(), 'unitPrice' => (float) $item->get_subtotal() / $item->get_quantity(), 'taxType' => 'IVA_RATE', 'tax' => 15, ]; }
$client = new FactuplanGuzzleClient( apiKey: get_option('factuplan_api_key'), taxpayerRuc: get_option('factuplan_ruc'), );
try { $factura = $client->emitirFactura([ 'customer' => $cliente, 'items' => $items, 'sendEmail' => true, ]);
$order->update_meta_data('_factuplan_access_key', $factura['accessKey']); $order->update_meta_data('_factuplan_pdf_url', $factura['pdfUrl']); $order->save(); } catch (Exception $e) { error_log("Factuplan error en pedido {$order_id}: " . $e->getMessage()); }});Hay un plugin oficial de Factuplan para WordPress en desarrollo — ver factuplan.com.ec/docs/plugin-wordpress.
Validación local del RUC en PHP
Antes de enviar al SRI, valida el dígito verificador del RUC del cliente para evitar errores 422:
function validarRuc(string $ruc): bool { if (!preg_match('/^\d{13}$/', $ruc)) return false; if (!str_ends_with($ruc, '001')) return false;
$provincia = (int) substr($ruc, 0, 2); if ($provincia < 1 || $provincia > 24) return false;
$tercerDigito = (int) $ruc[2]; $cedula = substr($ruc, 0, 10);
if ($tercerDigito >= 0 && $tercerDigito <= 5) { return validarCedula($cedula); // persona natural } elseif ($tercerDigito === 6) { return validarSociedadPublica($ruc); // sociedad pública } elseif ($tercerDigito === 9) { return validarSociedadPrivada($ruc); // sociedad privada }
return false;}
function validarCedula(string $cedula): bool { if (strlen($cedula) !== 10) return false; $coef = [2, 1, 2, 1, 2, 1, 2, 1, 2]; $sum = 0; for ($i = 0; $i < 9; $i++) { $prod = (int) $cedula[$i] * $coef[$i]; $sum += $prod >= 10 ? $prod - 9 : $prod; } $verificador = (10 - ($sum % 10)) % 10; return $verificador === (int) $cedula[9];}
// Ejemploif (!validarRuc('0993378150001')) { throw new InvalidArgumentException('RUC inválido');}O usa la herramienta visual de validación de RUC para chequear manualmente durante desarrollo.
Buenas prácticas en producción
1. Variables de entorno por ambiente
Usa keys diferentes en dev/staging/prod:
FACTUPLAN_API_KEY=ak_live_xxxFACTUPLAN_WEBHOOK_SECRET=whsec_prod_xxxNo comites el .env al repo.
2. Idempotencia
Si tu sistema reintenta una emisión por timeout, evita duplicados con un header Idempotency-Key:
$client->request('POST', '/v1/developer/invoices', $payload, [ 'headers' => ['Idempotency-Key' => "order-{$pedidoId}"]]);3. Logs estructurados
Loguea siempre accessKey, requestId, sriCode y statusCode:
error_log(json_encode([ 'event' => 'invoice.created', 'accessKey' => $factura['accessKey'], 'status' => $factura['status'], 'requestId' => $factura['meta']['requestId'] ?? null,]));Para logs serios, usa Monolog.
4. Procesar webhooks async
No bloquees el handler del webhook con lógica pesada. Encola y responde 200 rápido:
// En el handler del webhookqueue()->push(new ProcesarFacturaAutorizadaJob($event));return response()->json(['received' => true]);5. Rate limiting tuyo
Si tu app expone el endpoint que emite facturas, ponle rate limit propio para no consumir cuota Factuplan por bots.
Repositorio de ejemplo
El repo de referencia con todos estos snippets se publicará en GitHub pronto, junto con el plugin para WordPress y ejemplos para Laravel/Symfony. Por ahora la integración base se documenta acá y en factuplan.com.ec/docs/api.
Resumen práctico
- Crea cuenta y API key en
app.factuplan.com.ec. - PHP 8.1+ con extensiones
ext-curl,ext-json,ext-mbstring. - Implementa cliente con cURL nativo (sin dependencias) o con Guzzle (mejor ergonomía).
- Emite factura con
POST /v1/developer/invoicesenviando customer + items + payments. - Maneja errores diferenciando HTTP code (401 auth, 422 SRI, 429 cuota, 5xx server).
- Para webhooks, verifica HMAC del body crudo contra tu webhook secret antes de procesar.
- Valida localmente el RUC con dígito verificador para evitar errores 422 del SRI.
- Usa idempotency keys, backoff exponencial, logs estructurados y variables por ambiente.
Crea tu API key gratis → y empieza con el plan freemium (25 facturas/mes sin tarjeta).
Referencia completa de la API: factuplan.com.ec/docs/api Colección Postman: descarga desde tu dashboard de Factuplan. Plugin WordPress: factuplan.com.ec/docs/plugin-wordpress.
Preguntas frecuentes
¿Necesito Composer y Guzzle para integrar facturación electrónica en PHP?
No es estrictamente necesario. Puedes implementar todo con cURL nativo de PHP usando ext-curl (incluido en casi cualquier instalación). Esto es útil en hostings shared donde Composer no está disponible o cuando quieres minimizar dependencias. Sin embargo, Guzzle es muy recomendado si tu proyecto ya lo usa: maneja reintentos con middleware, async, streams, gestión de pool de conexiones y mejor ergonomía. Si trabajas con Laravel o Symfony, ya tienes Guzzle disponible. Si estás en un script PHP puro o hosting limitado, cURL nativo funciona perfectamente: solo añades tú mismo la lógica de reintentos con backoff.
¿Cómo manejo cuando el SRI tarda en autorizar una factura desde PHP?
En el esquema offline del SRI vigente desde 2022, las autorizaciones tardan típicamente 2-5 segundos pero pueden subir a varios minutos en cierres de mes o picos de declaración. Tres patrones para PHP: 1) Timeout amplio (30-60 segundos) en CURLOPT_TIMEOUT y Guzzle config, útil para UIs que pueden esperar; 2) Respuesta asíncrona: devuelves status PENDING al cliente con la accessKey y escuchas el webhook invoice.authorized en background para actualizar el estado final, evita bloquear el servidor; 3) Polling con GET /v1/developer/invoices/{id} cada N segundos hasta status AUTHORIZED. La opción 2 (webhooks) es la más robusta para producción porque desacopla la UX del tiempo del SRI y no mantiene conexiones HTTP abiertas innecesariamente.
¿Puedo emitir facturas electrónicas desde un plugin de WordPress / WooCommerce?
Sí. Usa el hook woocommerce_order_status_completed (o el estado que dispara la facturación en tu flujo) para llamar a la API de Factuplan con cURL o Guzzle. Mapea los campos de WooCommerce a la estructura del API: billing como customer, line_items como items, billing_email como customer.email. Para el tipo de identificación necesitas un campo custom donde el cliente ingrese cédula o RUC (puedes añadirlo con woocommerce_checkout_fields). Guarda la accessKey y pdfUrl como meta del pedido (update_meta_data) para mostrarlas al cliente en el dashboard de WooCommerce. Hay un plugin oficial de Factuplan en desarrollo, pero implementar la integración directa con cualquiera de los snippets de este post es totalmente viable y suele tomar 2-4 horas.
¿Cómo verifico la firma HMAC de un webhook en PHP correctamente?
Tres reglas clave: 1) Lee el body RAW con file_get_contents('php://input') ANTES de cualquier parseo a array, porque json_decode + json_encode no garantiza que el byte stream sea idéntico al original, y la firma HMAC se calcula sobre los bytes originales; 2) Usa hash_equals() para comparar, no === ni strcmp, porque hash_equals es timing-safe contra ataques de canal lateral; 3) En frameworks como Laravel, deshabilita el middleware CSRF y cualquier middleware que reparsee el body antes del handler del webhook (withoutMiddleware(['csrf'])). El cálculo es hash_hmac('sha256', $rawBody, $webhookSecret). Si la verificación falla con datos válidos, casi siempre es por algún middleware o pre-parseo que modificó el cuerpo antes de llegar al verificador.
¿Hay un SDK oficial de Factuplan para PHP como el de Node.js?
Actualmente el SDK oficial es para Node.js/TypeScript (paquete factuplan en npm). Para PHP la integración se hace contra la API REST directa, que está completamente documentada en factuplan.com.ec/docs/api con ejemplos en cURL, Guzzle, y otros lenguajes. Los snippets de este tutorial cubren los patrones esenciales: cliente HTTP base, emisión de facturas, manejo de errores tipados, webhooks con verificación HMAC y validación local de RUC. Un SDK oficial en PHP está en evaluación según la demanda. Mientras tanto, puedes encapsular los snippets de este post en tu propia clase FactuplanClient y reutilizarla en todo el proyecto, lo que en la práctica es muy similar a usar un SDK propio.
¿Qué pasa con el certificado P12 de mi firma electrónica? ¿Tengo que cargarlo desde PHP?
No. El certificado P12 se carga UNA sola vez en tu workspace de Factuplan a través del dashboard web (no por API): vas a Configuración → Firma electrónica → Cargar P12, e ingresas la contraseña del certificado. Factuplan lo almacena de forma cifrada y firma cada XML automáticamente cuando emites una factura desde PHP (o cualquier otro lenguaje). Tu código PHP nunca necesita ver el P12 ni la contraseña, lo cual es bueno para seguridad: si tu servidor PHP es comprometido, el atacante no obtiene tu firma electrónica. Cuando el certificado venza (típicamente cada 1-2 años según la entidad certificadora), recibirás un email recordatorio y deberás subir el nuevo P12 al dashboard.
¿Cómo manejo facturas a consumidor final desde PHP en un sistema POS?
En el campo customer, usa identificationType FINAL_CONSUMER con identification 9999999999999 (trece nueves) y legalName 'CONSUMIDOR FINAL'. El email es opcional para consumidor final (si no lo tienes, omites el campo o lo envías vacío). En sistemas POS de alto volumen (restaurantes, farmacias, supermercados) es común facturar TODO a consumidor final por velocidad, lo cual es legal mientras no excedas el monto tope por transacción establecido en la normativa vigente. Para montos mayores debes solicitar al cliente identificación (cédula o RUC). Implementa en tu PHP una verificación simple: si total >= monto_tope_consumidor_final entonces obligar formulario con identification, sino permitir checkout rápido como consumidor final.
Equipo Factuplan
Especialista en facturación electrónica
Equipo editorial de Factuplan, especializado en facturación electrónica y normativa tributaria del SRI.
Conocer al equipoEmpieza a facturar electrónicamente hoy
1 mes gratis al comprar tu firma electrónica con FirmaOK. Sin tarjeta de crédito, sin compromiso.
