Guia de Testing — Autenticacion Sanctum + API SIFEN
Fecha: 2026-05-04 (auditoría user_sessions, invitations register, system_settings, "die when all tabs closed") Stack: Laravel Sanctum, Bearer Token, Spatie Permission Endpoint base:
http://localhost:8000Swagger:http://localhost:8000/api/documentationDocusaurus:http://localhost:3000
Usuarios de prueba
| Password | Nombre | Rol | |
|---|---|---|---|
admin@onnix.com.py | secreto | Administrador | admin |
api_user@onnix.com.py | secreto | API User (Onnix) | operador |
lector@onnix.com.py | secreto | Lector | lector |
El password real lo pide internamente al equipo. La documentación pública usa
secretocomo placeholder para no exponerlo. Seeder:php artisan db:seed --class=UserSeeder
Endpoints disponibles
Auth
Política HTTP: solo
GETyPOSTen endpoints de la app por seguridad. Los endpoints de "borrado lógico" usanPOST .../revoke|remove|cancel.
| Método | Ruta | Auth | Descripción |
|---|---|---|---|
POST | /api/auth/token | ❌ Público | Genera Bearer token (máx 5/min) |
GET | /api/auth/me | ✅ Bearer | Usuario autenticado |
GET | /api/auth/tokens | ✅ Bearer | Lista tokens activos (id, device_name, last_used_at) |
POST | /api/auth/token/refresh | ✅ Bearer | Rota el token actual (revoca + emite nuevo) |
POST | /api/auth/token/revoke | ✅ Bearer | Revoca el token actual |
POST | /api/auth/tokens/revoke-all | ✅ Bearer | Revoca todos los tokens del usuario |
Web — Auth + Sesiones (no API REST, son rutas web)
| Método | Ruta | Auth | Descripción |
|---|---|---|---|
GET | /login | ❌ Público | Form de login (email o username + password) |
POST | /login | ❌ Público | Login web (rate-limit login 5/15 min) |
POST | /logout | ✅ Auth | Cerrar sesión |
POST | /logout-tab-closed | ✅ Auth | Cierre forzado por heartbeat (ver Configuración del Sistema). CSRF-exempt; valida security.die_on_tab_close server-side |
Web — Invitaciones y registro
| Método | Ruta | Auth | Descripción |
|---|---|---|---|
GET | /invitations/{token} | ❌ Público (signed) | Pantalla de aceptación; layout dinámico según auth() |
POST | /invitations/{token}/accept | ✅ Auth | Aceptar invitación (attach pivot + assignRole) |
GET | /invitations/{token}/register | ❌ Público (signed) | Form de registro vía invitación; redirige a accept si la cuenta ya existe |
POST | /invitations/{token}/register | ❌ Público (rate-limit 5/15 min) | Crea user + Auth::login + redirect a accept |
Detalle completo: ver Invitaciones y Registro.
Web — Módulos UI (Livewire Volt)
| Ruta | Permiso | Quién entra |
|---|---|---|
GET /usuarios | permission:user:manage + mount aborta si no es superadmin | Solo superadmin |
GET /roles | permission:role:manage | Superadmin (gestiona roles globales y per-empresa) |
GET /emisores | permission:emitter:manage | Superadmin + admin per-empresa |
GET /emisores/{empresa} | permission:emitter:manage | Superadmin + miembro de la empresa |
GET /timbrados | permission:emitter:manage | Superadmin + admin per-empresa |
GET /configuracion/seguridad | Auth + mount aborta si no es superadmin | Solo superadmin |
GET /home, /dashboard | Auth | Cualquier user logueado |
SIFEN — Sistema
| Metodo | Ruta | Permiso | Descripcion |
|---|---|---|---|
GET | /api/sifen/health | Publico | Estado del servicio |
SIFEN — Emisores
| Metodo | Ruta | Permiso | Descripcion |
|---|---|---|---|
GET | /api/sifen/emitters | sifen:read | Lista emisores registrados (filtros: ?active=true, ?ambiente=test) |
POST | /api/sifen/emitters | sifen:send | Crea un emisor nuevo (ruc, dv, razon_social, ambiente) |
POST | /api/sifen/emitters/{id} | sifen:send | Actualiza datos de un emisor (proximamente) |
POST | /api/sifen/emitters/{id}/cert | sifen:send | Sube certificado .p12 + password (proximamente) |
GET | /api/sifen/cert-check/{id} | sifen:read | Verifica que el P12 puede extraerse a PEM |
SIFEN — DTEs
| Metodo | Ruta | Permiso | Descripcion |
|---|---|---|---|
POST | /api/sifen/dtes | sifen:send | Crea DTE en draft (async, encola jobs) |
POST | /api/sifen/dtes/sync | sifen:send | Pipeline completo: crea + firma + envia a SIFEN (sync, 0260) |
POST | /api/sifen/dtes/lote | sifen:send | Envia DTEs firmados (signed) como lote async (0300) |
POST | /api/sifen/dtes/{id}/sign | sifen:send | Firma un DTE draft -> signed (XMLDSig) |
POST | /api/sifen/dtes/{id}/reenviar | sifen:send | Reencola un DTE rechazado/error para reenvio |
GET | /api/sifen/dtes/{id} | sifen:read | Estado del DTE + eventos + flag cancelado (cacheado 30min) |
GET | /api/sifen/dtes/{id}/xml | sifen:read | Descarga el XML firmado (Content-Type: application/xml) |
SIFEN — Eventos del emisor
| Metodo | Ruta | Permiso | Descripcion |
|---|---|---|---|
POST | /api/sifen/eventos/cancelacion | sifen:send | Cancela un DTE aprobado (plazo 48h FE / 168h otros) |
POST | /api/sifen/eventos/inutilizacion | sifen:send | Inutiliza rango de numeros de DE (max 1000) |
GET | /api/sifen/eventos/{id} | sifen:read | Detalle de un evento registrado |
SIFEN — Consultas directas a la DNIT
| Metodo | Ruta | Permiso | Descripcion |
|---|---|---|---|
GET | /api/sifen/consultas/ruc/{ruc} | sifen:read | Consulta RUC ante SIFEN (rConsRUC) |
GET | /api/sifen/consultas/de/{cdc} | sifen:read | Estado de un DE por CDC (rConsDe) |
GET | /api/sifen/consultas/de/{cdc}/factura | sifen:read | Contenido completo del DE (xContenDE parseado a JSON) |
Multitenant — Jerarquia tenant (empresas → timbrados → est → PE)
Permite gestionar la estructura multitenant: una empresa puede tener varios timbrados,
cada timbrado varios establecimientos, y cada establecimiento varios puntos de expedición.
El ultimo_numero del PE es el contador SIFEN del correlativo.
| Metodo | Ruta | Permiso | Descripcion |
|---|---|---|---|
GET | /api/empresas | sifen:read | Lista empresas del usuario autenticado (vía pivote empresa_user) |
GET | /api/empresas/{id}/timbrados | sifen:read | Timbrados activos de la empresa |
GET | /api/timbrados/{id}/establecimientos | sifen:read | Establecimientos activos del timbrado |
GET | /api/establecimientos/{id}/puntos-expedicion | sifen:read | PEs con ultimo_numero y proximo_numero |
POST | /api/empresas/{id}/timbrados | sifen:admin | Crea timbrado (409 si numero duplicado) |
POST | /api/timbrados/{id}/establecimientos | sifen:admin | Crea establecimiento (409 si codigo duplicado) |
POST | /api/establecimientos/{id}/puntos-expedicion | sifen:admin | Crea punto de expedición (409 si codigo duplicado) |
Decisión: mutaciones sobre entidades existentes (renovar timbrado, desactivar est/PE, ajustar contador) se hacen desde el módulo admin UI
/timbrados, no vía API.
BCP — Cotizaciones del Banco Central del Paraguay
Scraper diario del sitio público del BCP (08:15 AM Asuncion via scheduler). Uso típico:
obtener referencial para el campo tipo_cambio al emitir DTEs multi-moneda.
| Metodo | Ruta | Permiso | Descripcion |
|---|---|---|---|
GET | /api/bcp/cotizaciones | bcp:read | Última cotización de cada moneda (USD, EUR, BRL, ARS, UYU, CLP) |
GET | /api/bcp/cotizaciones/{moneda} | bcp:read | Cotización de una moneda específica (código ISO 4217) |
Total: 26 rutas (1 pública + 25 protegidas con Bearer token)
Opción 1 — Swagger UI
- Levantar el servidor:
php artisan serve
- Abrir en el navegador:
http://localhost:8000/api/documentation
- Ejecutar
POST /api/auth/tokencon:
{
"email": "api_user@onnix.com.py",
"password": "secreto",
"device_name": "swagger"
}
-
Copiar el valor del campo
tokende la respuesta. -
Click en el botón Authorize 🔒 (arriba a la derecha) → pegar el token → Authorize.
-
Todos los endpoints con candado ya están habilitados.
Opción 2 — curl
# 1. Login — obtener token
curl -s -X POST http://localhost:8000/api/auth/token \
-H "Content-Type: application/json" \
-d '{"email":"api_user@onnix.com.py","password":"secreto","device_name":"curl-test"}' \
| python3 -m json.tool
Respuesta esperada:
{
"token": "1|abc123...",
"type": "Bearer",
"expires_at": "2027-03-19T00:00:00+00:00"
}
# 2. Guardar el token en variable
TOKEN="1|abc123..." # <-- reemplazar con el token real
# 3. Verificar usuario autenticado
curl -s http://localhost:8000/api/auth/me \
-H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool
# 3b. Listar tokens activos
curl -s http://localhost:8000/api/auth/tokens \
-H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool
# → [{"id":1,"device_name":"swagger","last_used_at":"2026-03-19T...","created_at":"..."}]
# 4. Probar un endpoint SIFEN protegido
curl -s http://localhost:8000/api/sifen/health
# → {"status":"ok"} (público, sin token)
curl -s http://localhost:8000/api/sifen/cert-check/1 \
-H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool
# 5. Rotar token (revoca el actual y emite uno nuevo, mismas abilities)
curl -s -X POST http://localhost:8000/api/auth/token/refresh \
-H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool
# → {"token":"5|xyz789...","type":"Bearer"}
# IMPORTANTE: el TOKEN viejo ya no funciona, usar el nuevo
# 6. Revocar token actual
curl -s -X DELETE http://localhost:8000/api/auth/token \
-H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool
# 5b. Revocar TODOS los tokens (logout todos los dispositivos)
curl -s -X DELETE http://localhost:8000/api/auth/tokens \
-H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool
# → {"message":"Todos los tokens revocados.","revocados":3}
# 6. Verificar que fue revocado — debe retornar 401
curl -s http://localhost:8000/api/auth/me \
-H "Authorization: Bearer $TOKEN"
Opción 3 — Tinker (sin servidor HTTP)
php artisan tinker
// Crear token manualmente
$user = App\Models\User::where('email', 'api_user@onnix.com.py')->first();
$token = $user->createToken('test', ['sifen:read', 'sifen:send'])->plainTextToken;
dump($token);
// Ver todos los tokens activos del usuario
dump($user->tokens()->get(['id', 'name', 'created_at']));
// Revocar todos los tokens del usuario
$user->tokens()->delete();
// Verificar que no quedan tokens
dump($user->tokens()->count()); // → 0
Casos de error esperados
Credenciales incorrectas → 422
curl -s -X POST http://localhost:8000/api/auth/token \
-H "Content-Type: application/json" \
-d '{"email":"api_user@onnix.com.py","password":"wrong","device_name":"test"}'
{
"message": "Las credenciales proporcionadas son incorrectas.",
"errors": { "email": ["Las credenciales proporcionadas son incorrectas."] }
}
Sin token en ruta protegida → 401
curl -s http://localhost:8000/api/sifen/cert-check/1
{
"message": "Unauthenticated."
}
Token revocado → 401
Mismo response que sin token — Sanctum valida contra la tabla personal_access_tokens.
Demasiados intentos de login → 429
Tras 5 intentos fallidos en 1 minuto (por email + IP):
{
"message": "Too Many Attempts."
}
Headers de respuesta:
Retry-After: 60— segundos hasta que se libera el bloqueoX-RateLimit-Limit: 5X-RateLimit-Remaining: 0
La clave de throttle es
email|IP, así que dos usuarios distintos en la misma IP no se bloquean mutuamente.
Roles y permisos (Spatie Laravel Permission)
Los permisos se derivan automáticamente del rol del usuario al generar el token.
Roles disponibles
| Rol | Permisos | Usuario de prueba |
|---|---|---|
admin | sifen:read + sifen:send + sifen:admin + bcp:read | admin@onnix.com.py |
operador | sifen:read + sifen:send + bcp:read | api_user@onnix.com.py |
lector | sifen:read + bcp:read | lector@onnix.com.py |
Permisos por endpoint
| Permiso | Endpoints |
|---|---|
sifen:read | GET /emitters, GET /cert-check/{id}, GET /dtes/{id}, GET /dtes/{id}/xml, GET /eventos/{id}, GET /consultas/*, GET /empresas, GET /empresas/{id}/timbrados, GET /timbrados/{id}/establecimientos, GET /establecimientos/{id}/puntos-expedicion |
sifen:send | POST /emitters, POST /dtes, POST /dtes/sync, POST /dtes/lote, POST /dtes/{id}/sign, POST /dtes/{id}/reenviar, POST /eventos/cancelacion, POST /eventos/inutilizacion |
sifen:admin | POST /empresas/{id}/timbrados, POST /timbrados/{id}/establecimientos, POST /establecimientos/{id}/puntos-expedicion — gestión de la jerarquía tenant |
bcp:read | GET /bcp/cotizaciones, GET /bcp/cotizaciones/{moneda} |
Si el usuario no tiene ningún permiso asignado,
POST /api/auth/tokenretorna 422. Si intenta acceder a un endpoint sin el permiso requerido, retorna 403 Forbidden.
Asignar un rol en Tinker
$user = App\Models\User::where('email', 'ejemplo@onnix.com.py')->first();
$user->syncRoles(['lector']); // reemplaza todos los roles
$user->assignRole('operador'); // agrega un rol adicional
$user->getRoleNames(); // listar roles actuales
$user->getAllPermissions()->pluck('name'); // listar permisos efectivos
Tabla personal_access_tokens — inspección directa
php artisan tinker
// Ver todos los tokens activos
DB::table('personal_access_tokens')->get(['id','tokenable_id','name','abilities','last_used_at']);
Testing SIFEN — Envío Síncrono (POST /dtes/sync)
Pipeline completo en un solo request: crea DTE → firma XMLDSig → inyecta QR → envía a SIFEN.
Respuesta exitosa: 0260 — Autorización del DE satisfactoria.
TOKEN="<tu_token>"
curl -s -X POST http://localhost:8000/api/sifen/dtes/sync \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"tipoDocumento": 1,
"tipoEmision": 1,
"fechaEmision": "2026-03-23T14:00:00",
"tipoOperacion": 1,
"tipoImpuesto": 1,
"establecimiento": "001",
"puntoExpedicion": "001",
"numeroDocumento": "901",
"timbrado": 80126006,
"tipoContribuyente": 2,
"emisor": {
"ruc": "80126006",
"dv": "0",
"razonSocial": "ONNIX TECNOLOGIA Y SERVICIOS S.A.",
"direccion": "BELGICA Y GUIDO SPANO",
"numeroCasa": 0,
"departamento": 1,
"codigoDistrito": 1,
"descripcionDistrito": "ASUNCION (DISTRITO)",
"codigoCiudad": 1,
"descripcionCiudad": "ASUNCION (DISTRITO)",
"telefono": "0974111000",
"email": "INFO@ONNIX.COM.PY",
"actividadesEconomicas": [
{"codigo": "62090", "descripcion": "OTRAS ACTIVIDADES DE TECNOLOGIA DE LA INFORMACION Y SERVICIOS INFORMATICOS"}
]
},
"receptor": {
"naturaleza": 2,
"tipoDoc": 5,
"numeroDocumento": "0",
"nombre": "SIN NOMBRE"
},
"condicionPago": {"condicion": 1},
"items": [
{
"descripcion": "SERVICIOS BASICOS",
"unidadMedida": 77,
"cantidad": 1,
"precioUnitario": 500000,
"afectacionIva": 1,
"tasaIva": 10,
"codigoInterno": "SRV-001"
}
],
"totales": {
"subtotalExento": 0,
"subtotalGravado5": 0,
"subtotalGravado10": 454545,
"iva5": 0,
"iva10": 45455,
"ivaTotal": 45455,
"totalOperacion": 500000
}
}' | python3 -m json.tool
Respuesta esperada (HTTP 200):
{
"id": 11,
"cdc": "01801260060001001000085222026032319395037438",
"estado": "approved",
"sifen_result_code": "0260",
"sifen_result_msg": "Autorización del DE satisfactoria"
}
Nota: Cambiar
numeroDocumentopor un número nuevo en cada envío — SIFEN rechaza CDCs duplicados (1002).
Testing SIFEN — Envío por Lotes (POST /dtes/lote)
Flujo en dos pasos:
Paso 1 — Crear DTEs en draft
# Crear DTE en draft (cambiar numeroDocumento en cada llamada)
curl -s -X POST http://localhost:8000/api/sifen/dtes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{ <mismo JSON que sync, numeroDocumento diferente> }' \
| python3 -m json.tool
# → {"id": 20, "cdc": "...", "estado": "draft"}
Paso 2 — Firmar cada DTE via API
# Firmar DTE ID=20 (draft → signed)
curl -s -X POST http://localhost:8000/api/sifen/dtes/20/sign \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
# → {"id": 20, "cdc": "...", "estado": "signed"}
Repetir el paso 1 y 2 para cada DTE que quieras incluir en el lote.
Paso 2 — Enviar como lote
curl -s -X POST http://localhost:8000/api/sifen/dtes/lote \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"dte_ids": [20, 21, 22],
"batch_id": 1,
"emitter_id": 1
}' | python3 -m json.tool
Respuesta esperada (HTTP 200):
{
"cod_res": "0300",
"msg_res": "Lote recibido con éxito",
"prot_lote": "85789778101315144",
"dte_ids": [20, 21, 22],
"http_code": 200
}
prot_lote: número de protocolo de lote para consultar el resultado posterior en SIFEN. Los DTEs quedan en estado
sent. La DNIT procesa el lote de forma asíncrona.
Diferencia sync vs lote
Sync (/dtes/sync) | Lote (/dtes/lote) | |
|---|---|---|
| Endpoint SIFEN | recibe.wsdl | recibe-lote.wsdl |
| Código aprobación | 0260 BC01 | 0300 BF01 |
| Estado final DTE | approved | sent (pendiente DNIT) |
| Procesamiento | Inmediato | Asíncrono |
Testing SIFEN — Consultas directas a la DNIT
# Consultar RUC
curl -s "http://localhost:8000/api/sifen/consultas/ruc/80126006" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# Consultar estado de un DE por CDC
CDC="01801260060001001000085222026032319395037438"
curl -s "http://localhost:8000/api/sifen/consultas/de/$CDC" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# Obtener contenido completo de la factura (xContenDE parseado a JSON)
curl -s "http://localhost:8000/api/sifen/consultas/de/$CDC/factura" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Testing SIFEN — Emisores
# Listar todos los emisores
curl -s http://localhost:8000/api/sifen/emitters \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# Filtrar solo activos en ambiente test
curl -s "http://localhost:8000/api/sifen/emitters?active=true&ambiente=test" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# Crear un emisor nuevo
curl -s -X POST http://localhost:8000/api/sifen/emitters \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ruc": "80050172",
"dv": "3",
"razon_social": "NUEVA EMPRESA S.A.",
"ambiente": "test",
"establecimiento": "001",
"punto_expedicion": "001",
"nombre_fantasia": "NuevaEmpresa",
"id_csc": "0001",
"csc": "ABC123"
}' | python3 -m json.tool
# -> {"message": "Emisor creado exitosamente.", "emitter": {"id": 5, ...}}
# Verificar certificado de un emisor
curl -s http://localhost:8000/api/sifen/cert-check/1 \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# -> {"emitter_id": 1, "cert_pem_exists": true, "key_pem_exists": true, "status": "ok"}
Testing SIFEN — Descarga de XML firmado
# Descargar XML firmado de un DTE aprobado
curl -O -J http://localhost:8000/api/sifen/dtes/96/xml \
-H "Authorization: Bearer $TOKEN"
# -> Descarga: 01801260060001001000100122026041017345886385.xml
# Intentar descargar XML de un DTE en draft (debe dar 409)
curl -s http://localhost:8000/api/sifen/dtes/35/xml \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" | python3 -m json.tool
# -> {"message": "El DTE 35 aun no tiene XML firmado (estado: draft)."}
Testing SIFEN — Reenvio de DTEs
# Reenviar un DTE rechazado (resetea a signed y encola SendDteJob)
curl -s -X POST http://localhost:8000/api/sifen/dtes/6/reenviar \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" | python3 -m json.tool
# -> {
# "message": "DTE 6 reencolado para envio.",
# "dte_id": 6,
# "estado_anterior": "rejected",
# "estado_actual": "signed",
# "job": "SendDteJob"
# }
# Intentar reenviar un DTE aprobado (debe dar 409)
curl -s -X POST http://localhost:8000/api/sifen/dtes/93/reenviar \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" | python3 -m json.tool
# -> {"message": "El DTE 93 esta en estado approved y no puede reenviarse."}
Estados permitidos para reenvio:
| Estado actual | Accion | Job despachado |
|---|---|---|
draft | Firma + envio | BuildAndSignDteJob -> SendDteJob |
signed | Solo envio | SendDteJob |
rejected | Reset + envio | SendDteJob |
error | Reset + envio | SendDteJob |
approved / cancelled / sent | 409 bloqueado | — |
Testing SIFEN — Eventos (Cancelacion e Inutilizacion)
# Cancelar un DTE aprobado
curl -s -X POST http://localhost:8000/api/sifen/eventos/cancelacion \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cdc": "01801260060001001000100122026041017345886385",
"motivo": "Cancelacion por error en datos del receptor"
}' | python3 -m json.tool
# -> {"status": "approved", "cod_res": "0600", "prot_aut": "1089668", ...}
# Inutilizar un rango de numeros
curl -s -X POST http://localhost:8000/api/sifen/eventos/inutilizacion \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"timbrado": 80126006,
"establecimiento": "001",
"punto_expedicion": "001",
"numero_inicio": "0005001",
"numero_fin": "0005010",
"tipo_documento": 1,
"motivo": "Saltos en numeracion por reinicio del sistema"
}' | python3 -m json.tool
# -> {"status": "approved", "cod_res": "0600", "prot_aut": "...", "rango": "0005001 - 0005010"}
# Consultar un evento por ID
curl -s http://localhost:8000/api/sifen/eventos/7 \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Testing Multitenant — Jerarquia tenant
# 1. Listar empresas accesibles al user autenticado
curl -s http://localhost:8000/api/empresas \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# -> {"data":[{"id":1,"ruc":"80126006","razon_social":"ONNIX...","role":"admin"}, ...]}
# 2. Listar timbrados activos de una empresa
curl -s http://localhost:8000/api/empresas/1/timbrados \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# -> {"data":[{"id":1,"numero":"80126006","fecha_inicio":"2025-06-27",...}]}
# 3. Listar establecimientos de un timbrado
curl -s http://localhost:8000/api/timbrados/1/establecimientos \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# -> {"data":[{"id":1,"codigo":"001","descripcion":"Casa Central",...}]}
# 4. Listar PEs con contador del correlativo
curl -s http://localhost:8000/api/establecimientos/1/puntos-expedicion \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# -> {"data":[{"id":1,"codigo":"001","ultimo_numero":1172,"proximo_numero":1173,...}]}
# 5. Crear timbrado nuevo (requiere sifen:admin)
curl -s -X POST http://localhost:8000/api/empresas/1/timbrados \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"numero":"80126007","fecha_inicio":"2026-05-01","fecha_vencimiento":"2027-05-01"}' \
| python3 -m json.tool
# -> 201: {"data":{"id":10,"numero":"80126007",...}}
# -> 409 si el numero ya existe en la empresa
# 6. Crear establecimiento bajo un timbrado
curl -s -X POST http://localhost:8000/api/timbrados/10/establecimientos \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"codigo":"002","descripcion":"Sucursal Norte","direccion":"Av. España 1234"}' \
| python3 -m json.tool
# -> 201: {"data":{"id":12,"codigo":"002",...}}
# 7. Crear punto de expedicion con ultimo_numero inicial (migracion)
curl -s -X POST http://localhost:8000/api/establecimientos/12/puntos-expedicion \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"codigo":"001","descripcion":"Caja principal","ultimo_numero":0}' \
| python3 -m json.tool
# -> 201: {"data":{"id":15,"codigo":"001","ultimo_numero":0,"proximo_numero":1,...}}
# 8. Emitir un DTE usando los IDs nuevos (API poliglota)
curl -s -X POST http://localhost:8000/api/sifen/dtes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"timbrado_id": 10,
"establecimiento_id": 12,
"punto_expedicion_id": 15,
...
}' | python3 -m json.tool
# El PE reserva transaccionalmente el proximo numero (lockForUpdate + increment).
Errores comunes:
403— token sinsifen:adminintenta POST (solo read consifen:read).404— ID inexistente o no accesible al user.409—numero/codigoduplicado en el scope (empresa/timbrado/est).422— validación falló (codigo no es 3 dígitos zero-padded, fecha inválida, etc).
Testing BCP — Cotizaciones del Banco Central
# 1. Lista de todas las monedas
curl -s http://localhost:8000/api/bcp/cotizaciones \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# -> {"data":[{"moneda":"USD","referencial":7250.00,"fecha":"2026-04-22"}, ...]}
# 2. Cotizacion de una moneda especifica
curl -s http://localhost:8000/api/bcp/cotizaciones/USD \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# -> {"data":{"moneda":"USD","referencial":7250.00,"fecha":"2026-04-22","stale":false}}
# 3. Forzar sincronizacion manual desde CLI
php artisan bcp:sync-rates
# -> Sincroniza todas las monedas desde bcp.gov.py y persiste en bcp_cotizaciones
Fines de semana / feriados: el BCP no publica — el endpoint devuelve la última cotización hábil disponible con
stale=true.
Convencion de metodos HTTP
OnnixConnect usa solo GET y POST (no PUT/DELETE/PATCH):
| Metodo | Uso |
|---|---|
| GET | Lectura (consultas, descargas, listados) |
| POST | Acciones que crean o modifican estado (emitir, cancelar, reenviar, crear emisor/timbrado/est/PE) |
Razones: compatibilidad con firewalls corporativos, coherencia con SIFEN (que solo expone POST), simplicidad para integradores.
Para mutaciones sobre entidades ya creadas (ej: desactivar un timbrado, renombrar un establecimiento, ajustar ultimo_numero de un PE) usar el módulo admin UI /timbrados, que audita los cambios.
Verificación segura de Webhooks (HMAC SHA-256)
OnnixConnect firma cada delivery de webhook con HMAC-SHA256 usando el secret que el integrador configuró en la UI (/configuracion?tab=integraciones). El receiver debe verificar la firma con una comparación constant-time (hash_equals o equivalente) — usar == ó === plano filtra info de timing y permite que un atacante recupere la firma byte por byte.
Headers que envía OnnixConnect
X-Webhook-Id: 42
X-Webhook-Event: dte.approved
X-Webhook-Timestamp: 1715085600
X-Webhook-Signature: sha256=ab43f01...c2e9b
La firma se calcula sobre la concatenación {timestamp}.{body} (el cuerpo es el JSON con flags JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).
Verificación segura — PHP
$body = file_get_contents('php://input');
$receivedSig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$secret = getenv('ONNIX_WEBHOOK_SECRET');
$computedSig = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
// hash_equals: constant-time, seguro
if (! hash_equals($computedSig, $receivedSig)) {
http_response_code(401);
exit('Invalid signature');
}
// NO usar esto (timing attack):
// if ($computedSig === $receivedSig) { ... }
Verificación segura — Python
import hmac, hashlib
from flask import request, abort
body = request.get_data(as_text=True)
received_sig = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
computed_sig = 'sha256=' + hmac.new(
secret.encode(),
f'{timestamp}.{body}'.encode(),
hashlib.sha256
).hexdigest()
# compare_digest: constant-time, seguro
if not hmac.compare_digest(computed_sig, received_sig):
abort(401)
Verificación segura — Node.js / Express
const crypto = require('crypto');
app.post('/onnix-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const body = req.body.toString('utf8');
const receivedSig = req.header('X-Webhook-Signature') || '';
const timestamp = req.header('X-Webhook-Timestamp') || '';
const computedSig = 'sha256=' + crypto
.createHmac('sha256', process.env.ONNIX_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest('hex');
// timingSafeEqual: constant-time. Necesita Buffers del mismo largo.
const a = Buffer.from(computedSig);
const b = Buffer.from(receivedSig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
// ... procesar el body como JSON ...
});
Anti-replay (opcional pero recomendado)
El timestamp del header se firma junto con el body — no se puede falsificar. Tu receiver puede usarlo para rechazar entregas viejas:
// Rechazar si el timestamp tiene más de 5 minutos (300 segundos)
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit('Timestamp out of range');
}
Esto protege contra un atacante que capture una request válida y la reenvíe horas después.
Retries y determinismo
OnnixConnect reintenta cada delivery hasta 3 veces con backoff [10s, 60s, 300s]. La firma del header y el occurred_at del body son determinísticos entre intentos — mismo timestamp, mismo body, misma firma. Si tu receiver es idempotente (usa webhook_id + event como key), podés deduplicar deliveries duplicadas.
Probarlo
Para hacer tests rápidos: configurá un webhook apuntando a webhook.site en /configuracion?tab=integraciones y dispará un DTE de prueba. La UI muestra las últimas 50 deliveries con headers, body, status y duración.