Blog
Desarrolladores·17 de octubre de 2026 · 8 min

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.

Factura electrónica con PHP: emitir por API con cURL o Laravel

Respuesta corta: con PHP emitís una factura electrónica haciendo un POST JSON a https://api.facturaencr.com/v2/efactura/documents/factura con las cabeceras X-API-Key, X-API-Secret e Idempotency-Key, usando cURL o el cliente Http de Laravel. La API devuelve 202 con 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-Key y X-API-Secret. El secret se ve una sola vez.
  • Un emisor: el certificado .p12 del comercio subido, o EMISORPRUEBA para 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 (codigoTarifa 10, tarifa 0). Una línea sin impuesto termina en rechazo -1 de Hacienda.
  • Totales: no los mandés; se calculan desde las líneas.
  • Campos vacíos: '' y null se 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

RespuestaQué hacer
400 validation_errorLeé details[].path (formato detalle.0.impuesto.0) y corregí. No gastó consecutivo
409 idempotency_in_progressEsperá retryAfterSeconds y repetí con la misma clave
409 idempotency_conflictEsa clave ya se usó con otro cuerpo
429Respetá Retry-After
500 / 503Reintentá 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.