Factura electrónica con PHP: emitir por API con cURL o Laravel
En PHP emitís una factura electrónica v4.4 con cURL o el cliente HTTP de Laravel: POST con tres cabeceras, 202 con la clave y webhook firmado. Ejemplos.

Respuesta corta: con PHP emitís una factura electrónica haciendo un
POSTJSON ahttps://api.facturaencr.com/v2/efactura/documents/facturacon las cabecerasX-API-Key,X-API-SecreteIdempotency-Key, usando cURL o el clienteHttpde Laravel. La API devuelve202con la clave y el consecutivo, y el resultado de Hacienda te llega por webhook.
PHP sigue moviendo buena parte del comercio electrónico y de los sistemas administrativos en Costa Rica. Esta guía muestra cómo conectar uno a la facturación electrónica sin implementar XML, firma XAdES ni la comunicación con Hacienda.
Qué necesitás
- Llave de API del portal:
X-API-KeyyX-API-Secret. El secret se ve una sola vez. - Un emisor: el certificado
.p12del comercio subido, oEMISORPRUEBApara probar en el sandbox sin certificado. - PHP con la extensión cURL, o Laravel 7+ si querés usar su cliente HTTP.
Guardá las credenciales en el .env, fuera del repositorio. La llave decide el ambiente: la misma petición con la llave de producción emite un comprobante real.
Emitir con cURL
<?php
function efactura(string $metodo, string $ruta, ?array $body = null, ?string $idem = null): array {
$headers = [
'X-API-Key: ' . getenv('EFACTURA_KEY'),
'X-API-Secret: ' . getenv('EFACTURA_SECRET'),
'Content-Type: application/json',
];
if ($idem) $headers[] = 'Idempotency-Key: ' . $idem;
$ch = curl_init('https://api.facturaencr.com/v2/efactura' . $ruta);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $metodo,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
]);
$respuesta = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode($respuesta, true)];
}
$r = efactura('POST', '/documents/factura', [
'emisorLegalId' => '3101000000',
'condicionVenta' => '01',
'medioPago' => ['01'],
'receptor' => [
'tipoIdentificacion' => '01',
'numeroIdentificacion' => '100000002',
'nombre' => 'Juan Ejemplo',
'correoElectronico' => 'juan@ejemplo.cr',
],
'detalle' => [[
'codigoCabys' => '8999999999013', // ficticio: usá el real
'cantidad' => 1,
'unidadMedida' => 'Sp',
'detalle' => 'Honorarios contables',
'precioUnitario' => 85000, // sin IVA
'impuesto' => [['codigo' => '01', 'codigoTarifa' => '08', 'tarifa' => 13]],
]],
], 'pedido-' . $pedidoId);
if ($r['status'] === 202) {
guardarEmision($pedidoId, $r['body']['documentId'], $r['body']['clave'], $r['body']['consecutivo']);
}
Ojo con un detalle de PHP: impuesto y detalle son listas de objetos, por eso van con doble corchete [[ ... ]]. Un arreglo asociativo suelto se serializa como objeto y la validación lo rechaza.
Emitir con Laravel
use Illuminate\Support\Facades\Http;
$respuesta = Http::withHeaders([
'X-API-Key' => config('services.efactura.key'),
'X-API-Secret' => config('services.efactura.secret'),
'Idempotency-Key' => 'pedido-' . $pedido->id,
])
->timeout(30)
->post('https://api.facturaencr.com/v2/efactura/documents/factura', $payload);
if ($respuesta->status() === 202) {
$pedido->update([
'documento_id' => $respuesta->json('documentId'),
'clave' => $respuesta->json('clave'),
]);
} else {
$codigo = $respuesta->json('error'); // estable
$detalles = $respuesta->json('details') ?? [];
}
Si emitís desde una cola (ShouldQueue), dejá que el job reintente: con la misma Idempotency-Key un reintento nunca genera una segunda factura.
Lo que la API valida por vos
- IVA en cada línea, incluso exenta (
codigoTarifa10,tarifa0). Una línea sin impuesto termina en rechazo-1de Hacienda. - Totales: no los mandés; se calculan desde las líneas.
- Campos vacíos:
''ynullse tratan como no enviados, así que podés armar el payload directo desde las columnas de tu base. - Pago mixto: con más de un medio, cada uno lleva su monto y la suma tiene que dar el total, o la API corta antes de firmar.
Errores y reintentos
| Respuesta | Qué hacer |
|---|---|
400 validation_error | Leé details[].path (formato detalle.0.impuesto.0) y corregí. No gastó consecutivo |
409 idempotency_in_progress | Esperá retryAfterSeconds y repetí con la misma clave |
409 idempotency_conflict | Esa clave ya se usó con otro cuerpo |
429 | Respetá Retry-After |
500 / 503 | Reintentá con backoff y la misma clave |
Cuando abras un caso de soporte, incluí el requestId de la respuesta.
Recibir el webhook
<?php
function firmaValida(string $raw, string $cabecera, string $secret, int $tolerancia = 300): bool {
$partes = [];
foreach (explode(',', $cabecera) as $kv) {
[$k, $v] = explode('=', trim($kv), 2);
$partes[$k] = $v;
}
if (abs(time() - (int) $partes['t']) > $tolerancia) return false;
$esperado = hash_hmac('sha256', $partes['t'] . '.' . $raw, $secret);
return hash_equals($esperado, $partes['v1']);
}
$raw = file_get_contents('php://input'); // cuerpo crudo
if (!firmaValida($raw, $_SERVER['HTTP_X_EFACTURA_SIGNATURE'] ?? '', getenv('EFACTURA_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
http_response_code(200);
encolarEvento(json_decode($raw, true)); // procesá después
En Laravel el cuerpo crudo sale de $request->getContent(). Excluí esa ruta de la verificación CSRF, porque la llamada viene de nuestro servidor, no de un formulario.
El evento trae event (document.accepted o document.rejected), documentId y en data la clave, el consecutivo, el tipo y el ambiente. Si llega rejected, consultá GET /documents/{id}: el campo rechazo te dice qué código bloqueó y cómo arreglarlo.
Preguntas frecuentes
¿Sirve para WooCommerce o un sistema hecho a la medida?
Sí: cualquier código PHP que pueda hacer un POST HTTPS puede emitir. La lógica de cuándo facturar (al pagar el pedido, al despachar) la decidís vos.
¿Tengo que generar la clave de 50 dígitos?
No. La clave y el consecutivo se generan solos y vienen en el 202. Solo si tu sistema necesita imprimirlos antes existe la reserva previa (POST /clave/reserve).
¿Cómo descargo el XML y el PDF?
Con GET /documents/{id}/xml, /xml-respuesta y /pdf. Archivá el XML firmado y la respuesta de Hacienda: son el respaldo legal.
¿Qué hago con un rechazo?
No se corrige ni se reenvía: se emite uno nuevo que lo sustituye. Ver qué hacer si Hacienda rechaza una factura.
Con la API de Facturaencr tu sistema PHP manda JSON y nosotros nos encargamos del XML v4.4, la firma y Hacienda. El sandbox es gratis con 100 comprobantes al mes; los ejemplos de cada endpoint están en la documentación. Si tu tienda es Shopify, no hace falta programar: mirá la integración con Shopify.
Empezá a emitir con la API
Sandbox gratis. Tus primeros 100 documentos sin costo. Pagás solo por lo que emitís.
