Saltar al contenido principal

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:8000 Swagger: http://localhost:8000/api/documentation Docusaurus: http://localhost:3000


Usuarios de prueba

EmailPasswordNombreRol
admin@onnix.com.pysecretoAdministradoradmin
api_user@onnix.com.pysecretoAPI User (Onnix)operador
lector@onnix.com.pysecretoLectorlector

El password real lo pide internamente al equipo. La documentación pública usa secreto como placeholder para no exponerlo. Seeder: php artisan db:seed --class=UserSeeder


Endpoints disponibles

Auth

Política HTTP: solo GET y POST en endpoints de la app por seguridad. Los endpoints de "borrado lógico" usan POST .../revoke|remove|cancel.

MétodoRutaAuthDescripción
POST/api/auth/token❌ PúblicoGenera Bearer token (máx 5/min)
GET/api/auth/me✅ BearerUsuario autenticado
GET/api/auth/tokens✅ BearerLista tokens activos (id, device_name, last_used_at)
POST/api/auth/token/refresh✅ BearerRota el token actual (revoca + emite nuevo)
POST/api/auth/token/revoke✅ BearerRevoca el token actual
POST/api/auth/tokens/revoke-all✅ BearerRevoca todos los tokens del usuario

Web — Auth + Sesiones (no API REST, son rutas web)

MétodoRutaAuthDescripción
GET/login❌ PúblicoForm de login (email o username + password)
POST/login❌ PúblicoLogin web (rate-limit login 5/15 min)
POST/logout✅ AuthCerrar sesión
POST/logout-tab-closed✅ AuthCierre forzado por heartbeat (ver Configuración del Sistema). CSRF-exempt; valida security.die_on_tab_close server-side

Web — Invitaciones y registro

MétodoRutaAuthDescripción
GET/invitations/{token}❌ Público (signed)Pantalla de aceptación; layout dinámico según auth()
POST/invitations/{token}/accept✅ AuthAceptar 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)

RutaPermisoQuién entra
GET /usuariospermission:user:manage + mount aborta si no es superadminSolo superadmin
GET /rolespermission:role:manageSuperadmin (gestiona roles globales y per-empresa)
GET /emisorespermission:emitter:manageSuperadmin + admin per-empresa
GET /emisores/{empresa}permission:emitter:manageSuperadmin + miembro de la empresa
GET /timbradospermission:emitter:manageSuperadmin + admin per-empresa
GET /configuracion/seguridadAuth + mount aborta si no es superadminSolo superadmin
GET /home, /dashboardAuthCualquier user logueado

SIFEN — Sistema

MetodoRutaPermisoDescripcion
GET/api/sifen/healthPublicoEstado del servicio

SIFEN — Emisores

MetodoRutaPermisoDescripcion
GET/api/sifen/emitterssifen:readLista emisores registrados (filtros: ?active=true, ?ambiente=test)
POST/api/sifen/emitterssifen:sendCrea un emisor nuevo (ruc, dv, razon_social, ambiente)
POST/api/sifen/emitters/{id}sifen:sendActualiza datos de un emisor (proximamente)
POST/api/sifen/emitters/{id}/certsifen:sendSube certificado .p12 + password (proximamente)
GET/api/sifen/cert-check/{id}sifen:readVerifica que el P12 puede extraerse a PEM

SIFEN — DTEs

MetodoRutaPermisoDescripcion
POST/api/sifen/dtessifen:sendCrea DTE en draft (async, encola jobs)
POST/api/sifen/dtes/syncsifen:sendPipeline completo: crea + firma + envia a SIFEN (sync, 0260)
POST/api/sifen/dtes/lotesifen:sendEnvia DTEs firmados (signed) como lote async (0300)
POST/api/sifen/dtes/{id}/signsifen:sendFirma un DTE draft -> signed (XMLDSig)
POST/api/sifen/dtes/{id}/reenviarsifen:sendReencola un DTE rechazado/error para reenvio
GET/api/sifen/dtes/{id}sifen:readEstado del DTE + eventos + flag cancelado (cacheado 30min)
GET/api/sifen/dtes/{id}/xmlsifen:readDescarga el XML firmado (Content-Type: application/xml)

SIFEN — Eventos del emisor

MetodoRutaPermisoDescripcion
POST/api/sifen/eventos/cancelacionsifen:sendCancela un DTE aprobado (plazo 48h FE / 168h otros)
POST/api/sifen/eventos/inutilizacionsifen:sendInutiliza rango de numeros de DE (max 1000)
GET/api/sifen/eventos/{id}sifen:readDetalle de un evento registrado

SIFEN — Consultas directas a la DNIT

MetodoRutaPermisoDescripcion
GET/api/sifen/consultas/ruc/{ruc}sifen:readConsulta RUC ante SIFEN (rConsRUC)
GET/api/sifen/consultas/de/{cdc}sifen:readEstado de un DE por CDC (rConsDe)
GET/api/sifen/consultas/de/{cdc}/facturasifen:readContenido 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.

MetodoRutaPermisoDescripcion
GET/api/empresassifen:readLista empresas del usuario autenticado (vía pivote empresa_user)
GET/api/empresas/{id}/timbradossifen:readTimbrados activos de la empresa
GET/api/timbrados/{id}/establecimientossifen:readEstablecimientos activos del timbrado
GET/api/establecimientos/{id}/puntos-expedicionsifen:readPEs con ultimo_numero y proximo_numero
POST/api/empresas/{id}/timbradossifen:adminCrea timbrado (409 si numero duplicado)
POST/api/timbrados/{id}/establecimientossifen:adminCrea establecimiento (409 si codigo duplicado)
POST/api/establecimientos/{id}/puntos-expedicionsifen:adminCrea 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.

MetodoRutaPermisoDescripcion
GET/api/bcp/cotizacionesbcp:readÚltima cotización de cada moneda (USD, EUR, BRL, ARS, UYU, CLP)
GET/api/bcp/cotizaciones/{moneda}bcp:readCotizació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

  1. Levantar el servidor:
php artisan serve
  1. Abrir en el navegador:
http://localhost:8000/api/documentation
  1. Ejecutar POST /api/auth/token con:
{
"email": "api_user@onnix.com.py",
"password": "secreto",
"device_name": "swagger"
}
  1. Copiar el valor del campo token de la respuesta.

  2. Click en el botón Authorize 🔒 (arriba a la derecha) → pegar el token → Authorize.

  3. 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 bloqueo
  • X-RateLimit-Limit: 5
  • X-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

RolPermisosUsuario de prueba
adminsifen:read + sifen:send + sifen:admin + bcp:readadmin@onnix.com.py
operadorsifen:read + sifen:send + bcp:readapi_user@onnix.com.py
lectorsifen:read + bcp:readlector@onnix.com.py

Permisos por endpoint

PermisoEndpoints
sifen:readGET /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:sendPOST /emitters, POST /dtes, POST /dtes/sync, POST /dtes/lote, POST /dtes/{id}/sign, POST /dtes/{id}/reenviar, POST /eventos/cancelacion, POST /eventos/inutilizacion
sifen:adminPOST /empresas/{id}/timbrados, POST /timbrados/{id}/establecimientos, POST /establecimientos/{id}/puntos-expedicion — gestión de la jerarquía tenant
bcp:readGET /bcp/cotizaciones, GET /bcp/cotizaciones/{moneda}

Si el usuario no tiene ningún permiso asignado, POST /api/auth/token retorna 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 numeroDocumento por 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 SIFENrecibe.wsdlrecibe-lote.wsdl
Código aprobación0260 BC010300 BF01
Estado final DTEapprovedsent (pendiente DNIT)
ProcesamientoInmediatoAsí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 actualAccionJob despachado
draftFirma + envioBuildAndSignDteJob -> SendDteJob
signedSolo envioSendDteJob
rejectedReset + envioSendDteJob
errorReset + envioSendDteJob
approved / cancelled / sent409 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 sin sifen:admin intenta POST (solo read con sifen:read).
  • 404 — ID inexistente o no accesible al user.
  • 409numero/codigo duplicado 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):

MetodoUso
GETLectura (consultas, descargas, listados)
POSTAcciones 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.