azur / sdk
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).
Requires
- php: >=8.1
- ext-json: *
- guzzlehttp/guzzle: ^7.4
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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-Keyautomá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
- Instalación
- La credencial
- Primeros pasos
- Comprobantes
- Emisión directa (API clásica)
- Catálogos, inventario, cartera y demás
- Paginación y sincronización
- Webhooks
- Errores
- Reintentos e idempotencia
- Opciones del cliente
- 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_keydel 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,hastanibuscar, los listados de comprobantes se limitan a los últimos 60 días (lo dicedatos.ventanay 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-Keynueva (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']. Confalseno se manda ninguna. - Se reintenta, con backoff exponencial + jitter (
espera_base · 2^n, topeespera_maxima):- 429 siempre, respetando
Retry-After(lo da el limitador antes de procesar nada). Si pide esperar más queespera_maxima, se lanzaLimiteExcedido. - 502, 503, 504 y errores de red solo en lecturas y en escrituras con
Idempotency-Key. Nunca una escritura sin llave.
- 429 siempre, respetando
- 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.