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-rest.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) { $error = $decoded['error'] ?? []; throw new FactuplanApiException( $error['message'] ?? 'Error desconocido', $httpCode, $error['code'] ?? null, $error['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 "ID: {$factura['id']}\n"; echo "Estado: {$factura['status']}\n"; // DRAFT: la emisión sigue en segundo plano
// El PDF se pide aparte, cuando el comprobante ya está en COMPLETED: // GET /v1/developer/receipts/{id}/status → { status, authorizationNumber, messages } // GET /v1/developer/receipts/{id}/pdf → { url, previewUrl } (url expira en 5 minutos)} 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-rest.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); $error = $body['error'] ?? []; throw new FactuplanApiException( $error['message'] ?? 'Error de validación', $e->getCode(), $error['code'] ?? null, $error['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/receipts/{$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' => [ [ 'code' => 'SERV-001', 'description' => 'Servicio mensual', 'quantity' => 1, 'unitPrice' => 500.00, 'taxType' => 'IVA_RATE', 'tax' => 15, ], ], 'sendEmail' => true,]);
echo "Clave: {$factura['accessKey']}\n";echo "Estado: {$factura['status']}\n"; // DRAFT; el PDF se pide después en /v1/developer/receipts/{id}/pdf
Manejo de respuestas y errores
La API REST de Factuplan devuelve respuestas con el sobre estándar. Esta es la del POST /v1/developer/invoices de arriba; en los listados data es un arreglo y meta trae además total, page, limit y totalPages:
{ "data": { "id": "0199c2d4-7b1e-7c3a-9f40-2d6a8e5b1c77", "accessKey": "1305202401099337815000110010010000056781234567811", "sequential": "000005678", "status": "DRAFT", "total": "575", "source": "API", "apiKeyId": "0199c2d4-6a10-7c11-8d2e-1f2a3b4c5d6e" }, "meta": { "requestId": "0199c2d4-8f10-7a22-b3c1-5e9d7a4f6b20", "timestamp": "2026-05-27T12:34:56.789Z" }}Y los errores con:
{ "data": null, "meta": { "requestId": "0199c2d4-8f10-7a22-b3c1-5e9d7a4f6b20", "timestamp": "2026-05-27T12:34:56.789Z" }, "error": { "code": "GENERAL_0002", "message": "customer.El RUC debe tener 13 dígitos numéricos y terminar en 001", "statusCode": 400, "details": { "validation": [ "customer.El RUC debe tener 13 dígitos numéricos y terminar en 001" ] } }}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 |
409 | La devReference ya pertenece a otro documento (API_10011) o a uno anulado (API_10012) | Usar una referencia nueva; error.details trae el documento existente |
422 | Un dato del contribuyente o de su certificado impide procesar la petición (por ejemplo, un P12 inválido o vencido) | Leer error.message y corregirlo |
429 | Límite de peticiones (GENERAL_0003) o límite mensual de la API key (API_10002) | Con GENERAL_0003, esperar lo que indique Retry-After y repetir; con API_10002, reintentar no lo resuelve |
500-503 | Error temporal del servidor | Reintentar con backoff, enviando la misma devReference si es una emisión |
Un rechazo del SRI no llega como error HTTP: el
POSTresponde201con el comprobante enDRAFTy el rechazo aparece después como estadoREJECTEDoRETURNED(y en el webhookinvoice.rejected).
Reintentos con backoff (cURL)
Ni cURL ni PHP reintentan por ti una petición HTTP que falló. Reintenta solo los fallos temporales y, si es una emisión, envía siempre la misma devReference en el body: así un reintento nunca emite dos facturas. Implementa así:
function withBackoff(callable $fn, int $maxAttempts = 5): mixed { for ($attempt = 1; ; $attempt++) { try { return $fn(); } catch (FactuplanApiException $e) { // Solo fallos temporales: 408, 429 por límite de peticiones y 5xx. // Un 400, 401, 403, 409 o el 429 de API_10002 responden lo mismo aunque repitas. $temporal = in_array($e->statusCode, [408, 429, 500, 502, 503, 504], true) && $e->apiCode !== 'API_10002'; if (!$temporal || $attempt >= $maxAttempts) { throw $e; } } catch (RuntimeException $e) { // Error de red o timeout de cURL: no llegó ninguna respuesta if ($attempt >= $maxAttempts) { throw $e; } }
usleep(min(1000000 * (2 ** ($attempt - 1)), 30000000)); // microsegundos }}
// Uso$payload['devReference'] = "pedido-{$pedidoId}"; // la misma en cada intento$factura = withBackoff(fn () => $client->request('POST', '/v1/developer/invoices', $payload))['data'];
if ($factura['duplicate']) { // Un intento anterior sí llegó: no se emitió nada nuevo. Revisa $factura['status'].}Reintentos con Guzzle middleware
Guzzle tiene GuzzleHttp\RetryMiddleware. Este middleware reintenta también los POST, así que envía devReference en el body de cada emisión para que un reintento no emita dos veces. El ejemplo reintenta cualquier 429, incluido el de API_10002 (límite mensual de la API key), que seguirá respondiendo lo mismo hasta agotar los intentos:
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-rest.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 con un
2xx: cada intento espera 10 segundos y, si no llega, la entrega se reintenta.
Implementación con PHP puro
<?php// IMPORTANTE: leer el body RAW antes de cualquier parseo$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_X_FACTUPLAN_SIGNATURE'] ?? '';$secret = getenv('FACTUPLAN_WEBHOOK_SECRET');
// Sin el secret configurado no se puede verificar ninguna firma: fallar antes de calcular el HMACif (!is_string($secret) || $secret === '') { http_response_code(500); exit;}
// La cabecera llega como: t=<timestamp unix>,v1=<hmac sha256 en hex>$parts = [];foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); $parts[$key] = $value;}$timestamp = $parts['t'] ?? '';$signature = $parts['v1'] ?? '';
// La firma es el HMAC SHA-256 de "<timestamp>.<body crudo>"$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$valid = $timestamp !== '' && abs(time() - (int) $timestamp) <= 300 // rechaza entregas de más de 5 minutos && hash_equals($expected, $signature);
if (!$valid) { http_response_code(400); echo 'Invalid signature'; exit;}
// Procesar evento$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
switch ($event['event']) { case 'invoice.authorized': $accessKey = $event['data']['accessKey']; error_log("Factura autorizada: {$accessKey}"); // Tu lógica: marcar pedido como facturado, notificar al cliente, etc. // Si emitiste con devReference, $event['data']['devReference'] trae tu referencia // (junto con $event['data']['environment']: TESTING o PRODUCTION). break;
case 'invoice.rejected': $sriError = json_encode($event['data']['reason'] ?? 'sin detalle', JSON_UNESCAPED_UNICODE); 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) { $header = $request->header('x-factuplan-signature') ?? ''; $secret = config('services.factuplan.webhook_secret'); $rawBody = $request->getContent();
// Sin el secret configurado no se puede verificar ninguna firma: fallar antes de calcular el HMAC if (!is_string($secret) || $secret === '') { return response('Webhook secret no configurado', 500); }
// La cabecera llega como: t=<timestamp unix>,v1=<hmac sha256 en hex> $parts = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); $parts[$key] = $value; } $timestamp = $parts['t'] ?? ''; $signature = $parts['v1'] ?? '';
// La firma es el HMAC SHA-256 de "<timestamp>.<body crudo>" $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if ($timestamp === '' || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) { return response('Invalid signature', 400); }
$event = $request->json()->all();
match ($event['event']) { '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, // El hook puede dispararse más de una vez para el mismo pedido: // con la misma referencia, la segunda llamada devuelve la factura que ya existe. 'devReference' => "wc-pedido-{$order_id}", ]);
$order->update_meta_data('_factuplan_access_key', $factura['accessKey']); $order->update_meta_data('_factuplan_receipt_id', $factura['id']); // el PDF se pide después: GET /v1/developer/receipts/{id}/pdf $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, valida el dígito verificador del RUC del cliente: la API solo comprueba el formato (13 dígitos terminados en 001, y responde 400 si no lo cumple), no el dígito verificador. Este ejemplo lo calcula para personas naturales (tercer dígito del 0 al 5); para sociedades solo comprueba el formato:
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: dígito verificador de la cédula }
// Sociedad pública (6) o privada (9): este ejemplo solo comprueba el formato return $tercerDigito === 6 || $tercerDigito === 9;}
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 enviando tu propia referencia en el campo devReference del body (texto libre de 1 a 100 caracteres). No existe ningún header de idempotencia: ni Idempotency-Key ni X-Idempotency-Key tienen efecto.
$payload['devReference'] = "pedido-{$pedidoId}"; // la misma en cada reintento
$response = $client->request('POST', '/v1/developer/invoices', $payload);$factura = $response['data'];
if ($factura['duplicate']) { // Ya existía: no se emitió nada nuevo. Revisa $factura['status'].}La referencia es única por contribuyente, ambiente y tipo de documento. Con la misma referencia y el mismo contenido la API responde 201 con el documento existente y duplicate: true; con la misma referencia y otro contenido responde 409 (API_10011), y si ese documento fue anulado, 409 (API_10012). Para buscar por tu referencia: GET /v1/developer/invoices?devReference=pedido-1001. En pruebas los comprobantes se borran cada hora y su referencia vuelve a quedar libre; en producción una referencia usada queda ocupada para siempre.
3. Logs estructurados
Loguea siempre id, accessKey, status y el requestId de la respuesta (viene en meta, junto a data):
error_log(json_encode([ 'event' => 'invoice.created', 'id' => $factura['id'], 'accessKey' => $factura['accessKey'], 'status' => $factura['status'], 'requestId' => $response['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 (400 validación, 401 auth, 409
devReference, 429 límite o cuota, 5xx server); el rechazo del SRI no es un error HTTP: llega como estadoREJECTED. - Para webhooks, verifica la firma (
t=…,v1=…: HMAC de<timestamp>.<body crudo>) contra tu webhook secret antes de procesar. - Valida localmente el RUC con dígito verificador: la API solo comprueba el formato y responde 400 si no lo cumple.
- Usa
devReferencepara la idempotencia, 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 unos segundos pero pueden subir a varios minutos en cierres de mes o picos de declaración. La API no te hace esperar al SRI: el POST responde enseguida con el comprobante en estado DRAFT y la emisión sigue en segundo plano, así que no necesitas un timeout amplio. Dos patrones para PHP: 1) Webhooks: devuelves a tu cliente la accessKey con la factura en proceso y escuchas invoice.authorized (o invoice.rejected / invoice.error) para actualizar el estado final; 2) Polling con GET /v1/developer/receipts/{id}/status cada N segundos hasta status COMPLETED (AUTHORIZED es un estado de paso: el SRI ya autorizó y el PDF puede no estar listo) o hasta REJECTED, RETURNED o ERROR. La opción 1 (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 el id y la accessKey como meta del pedido (update_meta_data). La respuesta no trae un enlace al PDF: se pide después con GET /v1/developer/receipts/{id}/pdf, que devuelve una url temporal (5 minutos) y una previewUrl permanente. 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'])). La cabecera x-factuplan-signature llega como t=<timestamp>,v1=<firma>, y el cálculo es hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret), que se compara con el valor de v1; rechaza además las entregas cuyo timestamp tenga más de 5 minutos. 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 desde el dashboard web (Configuración → Firma electrónica → Cargar P12, con la contraseña del certificado) o por API, con POST /v1/developer/certificate (multipart: file y password); para renovarlo, PUT /v1/developer/certificate (multipart: file, password y ruc). 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 (desde el dashboard o con ese PUT).
¿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 obligatorio también para consumidor final: si lo omites o lo envías vacío, la API responde 400. Envía un correo de tu negocio y, si no quieres que se mande el RIDE, añade sendEmail: false. 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.
¿Te sirvió este artículo? Marca a Factuplan como fuente preferida y aparecerá más en tus resultados de Google.
Añadir a fuentes preferidas
¿Aún no tienes firma electrónica?
Sin ella no puedes emitir comprobantes ante el SRI. Consíguela 100% en línea.
Compra tu firma electrónica