Search by

azur / sdk

azurfacturacionelectronica

SDK oficial de PHP para la API de AZUR: facturación electrónica del SRI de Ecuador (comprobantes, catálogos, inventario, cartera, webhooks y emisión directa).

v1.1.0 2026-10-02 14:16 UTC

This package is auto-updated.

Last update: 2026-10-02 14:17:09 UTC


README

Facturación electrónica del SRI de Ecuador desde PHP: comprobantes (factura, nota de crédito y débito, guía, retención, liquidación, compra, proforma, nota de venta), clientes, productos, inventario, cartera, contabilidad, reportes (ATS), comprobantes recibidos y webhooks. También incluye la emisión directa de siempre (API clásica con api_key).

$azur = new Azur\Sdk\Azur('azur_test_…');

$borrador = $azur->facturas->guardar([
    'cliente'   => ['id_cliente' => 123],
    'productos' => [['id' => 45, 'cantidad' => 2]],
]);
$azur->facturas->enviar($borrador['id']);   // al SRI
  • PHP 8.1+, una sola dependencia: Guzzle 7.
  • Reintentos con backoff exponencial y jitter, Idempotency-Key automática en las escrituras.
  • Excepciones tipadas en español, iteradores para paginar y para sincronizar por cursor.
  • Verificación de la firma de los webhooks en tiempo constante.

Índice

  1. Instalación
  2. La credencial
  3. Primeros pasos
  4. Comprobantes
  5. Emisión directa (API clásica)
  6. Catálogos, inventario, cartera y demás
  7. Paginación y sincronización
  8. Webhooks
  9. Errores
  10. Reintentos e idempotencia
  11. Opciones del cliente
  12. Pruebas

Instalación

composer require azur/sdk

La credencial

La API v2 usa una credencial que crea el dueño de la cuenta en el portal: menú de usuario → Credenciales API. Al crearla elige el punto de emisión, los permisos (por ejemplo solo «Consultar comprobantes»), las IP autorizadas y un vencimiento opcional. El secreto se muestra una sola vez.

Prefijo Ambiente
azur_live_… Producción (efecto tributario real)
azur_test_… Pruebas del SRI

El ambiente lo fija la credencial, no la llamada: es imposible emitir en producción con una credencial de pruebas. Guárdela en una variable de entorno, nunca en el código:

$azur = new Azur\Sdk\Azur(getenv('AZUR_CREDENCIAL'));
echo $azur->ambiente(); // "pruebas" o "produccion"

La emisión directa usa otra clave: el api_key del punto de emisión (API_…). Vea Emisión directa.

Primeros pasos

use Azur\Sdk\Azur;

$azur = new Azur(getenv('AZUR_CREDENCIAL'));

$ctx = $azur->contexto();
echo $ctx->get('empresa.nombre'), ' · ', $ctx->get('ambiente.nombre');

$plan = $azur->plan();

Cada método devuelve una Azur\Sdk\Respuesta, que se usa como un array de solo lectura sobre datos, y además trae:

Propiedad / método Qué es
$r['campo'], $r->get('a.b') Lectura de datos (con notación de puntos en get)
$r->datos El array completo
$r->avisos Lo que conviene saber aunque saliera bien (algo se ignoró, la lista se limitó a 60 días…)
$r->lista() La lista principal de un listado (facturas, clientes…)
$r->limiteRestante() Llamadas que quedan en este minuto

Comprobantes

Recursos por tipo: facturas, creditos, debitos, guias, retenciones, liquidaciones, compras, proformas, notasVenta. Todos comparten estos métodos (el servidor decide cuáles aplican a cada tipo: la nota de débito y la retención no se editan; compra, proforma y nota de venta no van al SRI):

Método Endpoint
guardar($datos) / crear($datos) POST /{tipo}/guardar como borrador
crearYEnviar($datos) POST /{tipo}/guardar con accion: enviar (sale al SRI)
obtener($id) GET /{tipo}/{id}
editar($id, $datos) PUT /{tipo}/{id} (solo borradores)
eliminar($id) DELETE /{tipo}/{id}
listar($filtros) / todos($filtros) GET /{plural}
sincronizar($filtros) GET /{plural}?modificado_desde=… con cursor
enviar($id) POST /{tipo}/{id}/enviar
estado($id, $consultarSri = false) GET /{tipo}/{id}/estado
anular($id) POST /{tipo}/{id}/anular
reprocesar($id) POST /{tipo}/{id}/reprocesar
correo($id, $correo = null) POST /{tipo}/{id}/correo
pdf($id) / xml($id, 'autorizado') `GET /{tipo}/{id}/pdf

Extras: facturas->saldoAcreditable($id), saldoAcreditablePorNumero($numero), lineasAcreditables($id), cobros($id), cobrar($id, $datos); compras->retencionSugerida($id), pagos($id), pagar($id, $datos); liquidaciones->pagos/pagar; notasVenta->cobros/cobrar.

Crear una factura borrador

use Azur\Sdk\Excepciones\Validacion;

// Las búsquedas nunca eligen por usted: si `unico` es false, hay varios candidatos.
$clientes = $azur->clientes->buscar('0912345678');
$idCliente = $clientes['unico']
    ? $clientes['clientes'][0]['id']
    : $azur->clientes->crear([
        'identificacion'    => '0912345678',
        'nombrerazonsocial' => 'JUAN PÉREZ',
        'direccion'         => 'Av. 9 de Octubre, Guayaquil',
        'correo'            => 'juan@ejemplo.com',
    ])['id'];

try {
    $f = $azur->facturas->guardar([
        'cliente'     => ['id_cliente' => $idCliente],
        'productos'   => [['id' => 45, 'cantidad' => 2, 'descuento' => 0]],
        'formas_pago' => [['codigo' => '20', 'valor' => 23.00]],
    ]);
} catch (Validacion $e) {
    print_r($e->getMotivos());   // un motivo por cada cosa que falla
}

echo $f['id'], ' ', $f['estado'];   // borrador
// Los totales los calcula el servidor. Mire $f->avisos.

Cuando esté revisado: $azur->facturas->enviar($f['id']) y luego $azur->facturas->estado($f['id']) (o espere el webhook).

Listado unificado

$r = $azur->comprobantes->listar(['tipo' => 'factura,credito', 'desde' => '2026-09-01', 'limite' => 50]);
foreach ($r['comprobantes'] as $c) {
    echo $c['tipo_nombre'], ' ', $c['numero'], ' ', $c['estado'], "\n";
}

// Operar por tipo cuando el tipo llega como dato (nombre o código SRI):
$pdf = $azur->comprobantes->pdf('01', 123);
$pdf->guardar('/tmp');   // usa $pdf->nombre

Sin desde, hasta ni buscar, los listados de comprobantes se limitan a los últimos 60 días (lo dice datos.ventana y un aviso).

Emisión directa (API clásica)

La API de siempre para emitir en una sola llamada (genera el XML, firma y envía al SRI). Se autentica con el api_key del punto de emisión (API_…, en Configuración → Puntos de emisión); el ambiente lo define ese punto.

use Azur\Sdk\Emision;

$emision = new Emision(getenv('AZUR_API_KEY'), [
    'clave_en' => 'cabecera',   // 'cuerpo' (por defecto, campo api_key) o 'cabecera' (X-Api-Key)
]);

$r = $emision->factura([
    'comprador' => [
        'tipo_identificacion' => '05', 'identificacion' => '0912345678',
        'razon_social' => 'JUAN PÉREZ', 'direccion' => 'Guayaquil', 'correo' => 'juan@ejemplo.com',
    ],
    'items' => [[
        'codigo_principal' => 'SKU-1', 'descripcion' => 'Producto X',
        'cantidad' => 2, 'precio_unitario' => 10.00, 'tipoproducto' => 2, 'tipo_iva' => 4,
    ]],
    'pagos' => [['tipo' => '20', 'total' => 23.00]],
]);

echo $r->claveAcceso(), ' ', $r->numero();         // 49 dígitos · 001-001-000000123
$estado = $emision->consultar($r->claveAcceso());  // estado(): 4 = autorizado

Si no los manda, el SDK pone codigoDoc, emisor.fecha_emision (hoy, hora de Ecuador) y emisor.manejo_interno_secuencia = "SI" (AZUR asigna el secuencial sin duplicados).

Método Endpoint
factura, notaCredito, notaDebito, retencion20 (retención 2.0, recomendada), retencion (1.0, obsoleta), liquidacion, guia (o emitir($tipo, $datos)) POST /{tipo}/emision (retencion20 → /retencionats/emision)
consultar($claveAcceso) POST /consulta/comprobante
cupo($usuarioPrincipal) POST /consultar/comprobantes (usuario del dueño de la cuenta)
reenviarCorreo($claveAcceso, $motivo) POST /enviar/correo
marcarPagada($claveAcceso, true) POST /factura/pagada
guardarCliente($comprador) POST /clientes/nuevo
crearProductos($items) / actualizarProductos($items) POST /crear/productos, /actualizar/productos
stock($establecimiento, $codigo, $idBodega) POST /productos/obtenerstock

La API clásica responde HTTP 200 también cuando rechaza ({"creado": false, "errors": …}). El SDK lo normaliza: lanza Validacion, CredencialInvalida, CupoAgotado o Conflicto (secuencial ocupado). Con 'lanzar_excepciones' => false devuelve la RespuestaClasica con exito, errores y datos.

Reintentos en la emisión clásica: no admite Idempotency-Key, así que una emisión solo se reintenta ante un 429 (el limitador la rechazó antes de procesarla). Ante un corte de red o un 5xx no se reintenta: pudo haberse emitido. Consulte antes de volver a emitir.

Catálogos, inventario, cartera y demás

// Catálogos con ficha: clientes, proveedores, transportistas, productos
$azur->clientes->buscar('PÉREZ', ['limite' => 20]);
$azur->clientes->obtener(1); $azur->clientes->crear([...]); $azur->clientes->editar(1, [...]);
$azur->clientes->eliminar(1); $azur->clientes->activar(1);
$azur->clientes->sucursales(1); $azur->clientes->crearSucursal(1, [...]);   // también proveedores
$azur->productos->stock(45); $azur->productos->lotes(45); $azur->productos->categorias();
$azur->productos->crearCategoria(['nombre' => 'Repuestos']);
$azur->catalogos->sri(); $azur->catalogos->unidades(); $azur->catalogos->ice(); $azur->catalogos->irbpnr();

// Cuenta
$azur->cuenta->firma(); $azur->cuenta->vendedores(); $azur->cuenta->secuenciales();
$azur->cuenta->establecimientos(); $azur->cuenta->puntosEmision();

// Inventario
$azur->inventario->existencias(['solo_con_stock' => true]);
$azur->inventario->bodegas(); $azur->inventario->kardex(45, ['desde' => '2026-09-01']);
$azur->inventario->ingreso(['id_bodega' => 1, 'productos' => [['id' => 45, 'cantidad' => 10, 'costo' => 2.5]]]);
// salida(), transferencia() (con id_bodega_destino), ajuste()

// Cartera ('cobrar' | 'pagar')
$azur->cartera->documentos('cobrar', ['vencidos' => true]);
$azur->cartera->personas('cobrar'); $azur->cartera->movimientos('cobrar', ['id_cliente' => 1]);
$azur->facturas->cobrar(123, ['valor' => 50, 'forma' => 'transferencia', 'id_banco' => 2]);

// Contabilidad
$azur->contabilidad->cuentas(soloMovimiento: true); $azur->contabilidad->asientos(['desde' => '2026-09-01']);
$azur->contabilidad->crearAsiento(['concepto' => '…', 'lineas' => [...]]);

// Reportes
$azur->reportes->ats(2026, 9)->guardar('/tmp');   // XML del ATS
$azur->reportes->ventas(['agrupar' => 'cliente']); $azur->reportes->resumenVentas();

// Recibidos (por clave de acceso)
$azur->recibidos->importar($clave); $azur->recibidos->obtener($clave);
$azur->recibidos->pdf($clave); $azur->recibidos->aCompra($clave, ['es_gasto' => true]);

¿Un endpoint que el SDK aún no envuelve? $azur->solicitar('GET', 'ruta/nueva', ['param' => 1]).

Paginación y sincronización

// Todas las páginas, fila a fila (generador):
foreach ($azur->productos->todos(['limite' => 100]) as $p) { /* … */ }

// Mantener un ERP al día: lo que CAMBIÓ (emitido, autorizado, rechazado, anulado…)
$sync = $azur->comprobantes->sincronizar(['modificado_desde' => $ultimaVez]);
foreach ($sync as $c) {
    upsert($c['recurso'], $c['id'], $c);    // puede repetirse alguna fila: guarde por id
}
$ultimaVez = $sync->getReturn()['proximo_desde'];   // guárdelo para la próxima vez

sincronizar() (en comprobantes, cada tipo de documento y recibidos) filtra por fecha de modificación y sigue siguiente_cursor solo. Es lo correcto para sincronizar: una factura antigua que se anula hoy aparece; paginar por número sobre una tabla que crece se descoloca. proximo_desde es la hora de Ecuador en que empezó la sincronización menos 60 s de margen.

Webhooks

$w = $azur->webhooks->crear('https://mi-sitio.ec/webhooks/azur', ['comprobante.autorizado', 'comprobante.rechazado']);
$secreto = $w['secreto'];      // whsec_… — SOLO se devuelve ahora: guárdelo
$azur->webhooks->listar(); $azur->webhooks->envios($w['id']); $azur->webhooks->eliminar($w['id']);

Eventos: comprobante.autorizado, comprobante.rechazado, comprobante.anulado, plan.cupo_bajo.

Cada aviso trae X-Azur-Firma = hex(HMAC-SHA256(secreto, X-Azur-Momento + "." + cuerpo)), X-Azur-Momento (Unix), X-Azur-Evento y X-Azur-Intento. Webhook::verificar() comprueba la firma en tiempo constante y que el momento esté dentro de la tolerancia (300 s por defecto, contra reenvíos). Use el cuerpo crudo: si lo decodifica y lo vuelve a codificar, la firma no coincide.

Laravel

use Azur\Sdk\Webhook;
use Azur\Sdk\Excepciones\FirmaWebhookInvalida;

Route::post('/webhooks/azur', function (Illuminate\Http\Request $request) {
    try {
        $evento = Webhook::verificar($request->getContent(), $request->headers->all(), config('services.azur.webhook_secret'));
    } catch (FirmaWebhookInvalida $e) {
        abort(400);
    }
    ProcesarAvisoAzur::dispatch($evento);   // responda rápido; procese en cola
    return response()->noContent();
});

La ruta debe quedar fuera de la verificación CSRF (las de routes/api.php ya lo están).

PHP puro

$evento = Webhook::verificar(file_get_contents('php://input'), getallheaders(), getenv('AZUR_WEBHOOK_SECRETO'));
// $evento = ['evento' => 'comprobante.autorizado', 'momento' => '…', 'datos' => [...], 'intento' => 1]
http_response_code(200);

AZUR reintenta a los 1, 5 y 15 minutos si no responde 2xx, así que un aviso puede llegar más de una vez: procéselo de forma idempotente (por claveacceso + evento). Para probar su receptor: Webhook::cabecerasDePrueba($cuerpo, $secreto, 'comprobante.autorizado').

Errores

Todas las excepciones heredan de Azur\Sdk\Excepciones\AzurException y traen getCodigo() (estable, para el programa), getMessage() (para la persona), getDetalle(), esReintentable(), getEstadoHttp(), getRequestId(), getCuerpo() y getIdempotencyKey().

Excepción HTTP Códigos de AZUR
CredencialInvalida 401 credencial_invalida
SinPermiso 403 sin_permiso (permiso de la credencial o IP no autorizada)
NoEncontrado 404 no_encontrado
Validacion 422 datos_invalidos — getMotivos() con la lista de motivos
CupoAgotado 402 cupo_agotado
Conflicto 409, 300 secuencial_ocupado, documento_no_editable, establecimiento_cerrado, firma_caducada, no_disponible, anulacion_no_solicitada, anulacion_fuera_de_plazo, ambiguo
LimiteExcedido 429 demasiadas_peticiones — getReintentarEn()
ErrorServidor 5xx error_interno, sri_no_disponible, o respuesta no interpretable
ErrorRed — error_red: DNS, conexión, TLS, tiempo agotado
FirmaWebhookInvalida — firma_ausente, firma_invalida, momento_fuera_de_tolerancia, momento_invalido, cuerpo_invalido
try {
    $azur->facturas->enviar(123);
} catch (Validacion $e) {
    print_r($e->getMotivos());
} catch (Conflicto $e) {
    if ($e->getCodigo() === 'firma_caducada') { /* renovar la firma */ }
} catch (ErrorRed | ErrorServidor $e) {
    // Reintente más tarde con la MISMA llave para no duplicar:
    $azur->facturas->enviar(123, ['idempotency_key' => $e->getIdempotencyKey()]);
}

Reintentos e idempotencia

  • Cada escritura (POST, PUT, DELETE) lleva una Idempotency-Key nueva (UUID v4), la misma en los reintentos automáticos de esa llamada. Si se corta la conexión y se reintenta, AZUR devuelve el resultado original en vez de crear otro documento (la recuerda 24 h).
  • Pase la suya para poder reintentar usted mismo más tarde (por ejemplo desde una cola): ['idempotency_key' => 'pedido-1234']. Con false no se manda ninguna.
  • Se reintenta, con backoff exponencial + jitter (espera_base · 2^n, tope espera_maxima):
    • 429 siempre, respetando Retry-After (lo da el limitador antes de procesar nada). Si pide esperar más que espera_maxima, se lanza LimiteExcedido.
    • 502, 503, 504 y errores de red solo en lecturas y en escrituras con Idempotency-Key. Nunca una escritura sin llave.
  • Límites de la API v2: 300 lecturas y 60 escrituras por minuto por credencial.

Opciones del cliente

new Azur($credencial, [
    'base_url'                => 'https://azur.com.ec/plataforma/api/v2', // o solo el dominio
    'timeout'                 => 30,     // s
    'timeout_conexion'        => 10,     // s
    'reintentos'              => 3,
    'espera_base'             => 0.5,    // s
    'espera_maxima'           => 30,     // s
    'idempotencia_automatica' => true,
    'cabeceras'               => [],     // cabeceras extra
    'http_client'             => $guzzle, // su GuzzleHttp\ClientInterface (proxy, logs…)
    'handler'                 => $pila,   // o solo un HandlerStack
]);

Emision acepta las mismas más clave_en (cuerpo|cabecera) y lanzar_excepciones; su timeout por defecto es 60 s.

Pruebas

composer install
vendor/bin/phpunit --testsuite unitarios

# Integración contra un servidor real (solo lecturas; nunca con una credencial azur_live_):
AZUR_SDK_CREDENCIAL=azur_test_… AZUR_SDK_BASE_URL=https://azur.com.ec \
AZUR_SDK_API_KEY=API_…   # opcional: punto en ambiente de PRUEBAS
vendor/bin/phpunit --testsuite integracion

tests/Unit/CoberturaOpenApiTest.php comprueba que cada operación pública del OpenAPI (tests/fixtures/operaciones-openapi.json, sacado de GET /plataforma/api/v2/openapi) tiene su método y llama al método HTTP y la ruta correctos.

Documentación de la API: https://azur.com.ec/api · OpenAPI: https://azur.com.ec/plataforma/api/v2/openapi

Licencia

MIT. Vea LICENSE.