SIFEN QR — Referencia Técnica v150
Fuente primaria: Manual Técnico SIFEN v150, Capítulo 13 (KuDE), §13.8.3–13.8.4 Fuente secundaria: Código Java de referencia
rshk-jsifen-maven(DocumentoElectronico.java,SifenUtil.java) Última revisión: 2026-03-16
1. Descripción del Código QR
El QR (campo dCarQR en gCamFuFD) es una URL que permite al receptor consultar el DTE en el portal e-kuatia de la SET. Se genera después de firmar el XML porque depende del DigestValue de la firma.
El QR no forma parte del bloque firmado (gCamFuFD está fuera de <DE>), por lo que inyectarlo post-firma no invalida la firma digital.
2. URL Base por Ambiente
| Ambiente | URL base |
|---|---|
| Test | https://ekuatia.set.gov.py/consultas-test/qr? |
| Producción | https://ekuatia.set.gov.py/consultas/qr? |
3. Parámetros de la URL — Orden Obligatorio
El orden es fijo y mandatorio. Cambiar el orden altera el hash cHashQR.
| # | Parámetro | Fuente en XML | Long. | Hex | Notas |
|---|---|---|---|---|---|
| 1 | nVersion | Constante | 3 | No | Siempre "150" |
| 2 | Id | DE@Id (CDC) | 44 | No | CDC completo incluyendo DV |
| 3 | dFeEmiDE | gDatGralOpe/dFeEmiDE | 38* | Sí | Hex UTF-8 de yyyy-MM-dd'T'HH:mm:ss |
| 4a | dRucRec | gDatRec/dRucRec | ≤20 | No | Solo si iNatRec=1 (contribuyente) |
| 4b | dNumIDRec | gDatRec/dNumIDRec | ≤20 | No | Si iNatRec=2 y no es exportación |
| 4c | dNumIDRec | "0" | 1 | No | Si exportación (iTiOpe=4) o sin receptor |
| 5 | dTotGralOpe | gTotSub/dTotGralOpe | ≤23 | No | "0" si iTiDE=7 (remisión) |
| 6 | dTotIVA | gTotSub/dTotIVA | ≤23 | No | Solo si iTImp=1 o 5; si no → "0" |
| 7 | cItems | Conteo de gCamItem | ≤3 | No | No incluido en el XML del DE (calculado) |
| 8 | DigestValue | Signature/SignedInfo/Reference/DigestValue | 88* | Sí | Ver §4 |
| 9 | IdCSC | Configuración emisor | 4 | No | Siempre 4 dígitos con ceros: leftPad('0', 4) |
| 10 | cHashQR | Calculado | 64 | No | SHA-256 hex de params(1-9) + CSC. No incluido en el hash |
* Para SHA-256: dFeEmiDE siempre 38 chars, DigestValue siempre 88 chars.
4. DigestValue — Codificación Correcta (CRÍTICO)
Regla del Manual (§13.8.3)
"Los siguientes campos deben ser convertidos a su equivalente hexadecimal: Fecha de Emisión y DigestValue de la Firma Digital"
El manual convierte la cadena Base64 (incluyendo el padding =) a hexadecimal, NO los bytes crudos.
Ejemplo del Manual (pág. 207)
| Entrada (Base64) | Salida (Hex) | Chars |
|---|---|---|
yzGYhUx1/XYYzksWB+fPR3Qc50c= | 797a4759685578312f5859597a6b7357422b6650523351633530633d | 56 |
Verificación: bin2hex('yzGYhUx1/XYYzksWB+fPR3Qc50c=') = 797a...633d ✓ (28 chars × 2 = 56 chars hex)
Para SHA-256 (32 bytes)
DigestValue XML (Base64): "Ee+Ot6s4WYE+lXV9yf2S39lgx2LYeIJKLZBwbu2daPY=" (44 chars)
DigestValue hex para QR: "45652b4f..." (88 chars)
44 chars de Base64 × 2 = 88 chars hex — siempre para SHA-256.
PHP correcto
// El $digestB64 viene del XML: <DigestValue>Ee+Ot6s4...</DigestValue>
$digestB64 = trim($digestNode->textContent);
// ✅ CORRECTO — convierte los bytes ASCII del string Base64 a hex (88 chars para SHA-256)
$digestHex = bin2hex(base64_encode((string) base64_decode($digestB64)));
// Equivalente simplificado (base64_decode + base64_encode = identidad):
// $digestHex = bin2hex($digestB64);
// ❌ INCORRECTO — hex de los bytes crudos del hash (produce 64 chars)
// $digestHex = bin2hex(base64_decode($digestB64));
// ❌ INCORRECTO — incluir el Base64 crudo sin hexificar
// $digestHex = $digestB64;
Java de referencia (SifenUtil.java)
// getDigestValue() retorna byte[] crudos (32 bytes SHA-256)
byte[] rawDigest = ((Reference) signedInfo.getReferences().get(0)).getDigestValue();
// encode() convierte a string Base64 y lo retorna como byte[] (bytes del string ASCII)
byte[] base64Bytes = Base64.getEncoder().encode(rawDigest);
// bytesToHex convierte esos bytes ASCII a hex lowercase → 88 chars
String digestValue = SifenUtil.bytesToHex(base64Bytes);
5. Fecha dFeEmiDE — Hex de UTF-8
"2026-03-12T10:00:00" → bin2hex() → "323032362d30332d31325431303a30303a3030"
- Siempre 38 caracteres hex (19 chars ASCII × 2)
- Formato:
yyyy-MM-dd'T'HH:mm:ss(sin timezone, sin milisegundos)
// ✅ CORRECTO
$dFeEmiDE = bin2hex($dte->fecha_emision->format('Y-m-d\TH:i:s'));
6. Construcción del cHashQR
Paso 1 — urlParamsString (sin cHashQR):
"nVersion=150&Id={CDC}&dFeEmiDE={hex}&dRucRec={ruc}&dTotGralOpe={n}&dTotIVA={n}&cItems={n}&DigestValue={88hex}&IdCSC={4d}"
Paso 2 — concatenar CSC directamente (SIN "&" separador):
input = urlParamsString + CSC
ejemplo: "...&IdCSC=0001ABCD0000000000000000000000000000"
Paso 3 — SHA-256 hex lowercase:
cHashQR = hash('sha256', input) // 64 chars lowercase
Reglas críticas:
- Sin
&entreIdCSC=XXXXy el CSC - Sin trailing
&en urlParamsString antes del hash - Construir el query string manualmente con
implode('&', ...)— NO usarhttp_build_query(URL-encodea-y+)
// ✅ CORRECTO — construcción manual del query string
$parts = [];
foreach ($params as $k => $v) {
$parts[] = "{$k}={$v}";
}
$urlParams = implode('&', $parts);
$cHashQR = hash('sha256', $urlParams . $csc); // lowercase por defecto en PHP
$url = $baseUrl . '?' . $urlParams . '&cHashQR=' . $cHashQR;
7. Ejemplo Completo del Manual (§13.8.4)
Datos de entrada
| Parámetro | Valor |
|---|---|
| nVersion | 150 |
| Id | 0144444440170010010014528220170125158732 60988 |
| dFeEmiDE | 2017-01-25T09:35:17 |
| dRucRec | 88899990 |
| dTotGralOpe | 300000 |
| dTotIVA | 27272 |
| cItems | 2 |
| DigestValue (Base64) | yzGYhUx1/XYYzksWB+fPR3Qc50c= |
| DigestValue (Hex) | 797a4759685578312f5859597a6b7357422b6650523351633530633d |
| IdCSC | 0001 |
| CSC | ABCD0000000000000000000000000000 |
urlParamsString (Paso 1)
nVersion=150&Id=01444444401700100100145282201701251587326098 8&dFeEmiDE=323031372d30312d32355430393a33353a31 37&dRucRec=88899990&dTotGralOpe=300000&dTotIVA=27272&cItems=2&DigestValue=797a4759685578312f5859597a6b7357422b6650523351633530633d&IdCSC=0001
Concatenar CSC (Paso 2)
...&IdCSC=0001ABCD0000000000000000000000000000
↑ sin "&", CSC pegado directo
SHA-256 (Paso 3)
cHashQR = 97ddbb3c1e7d65af03a70ffe21f2b34846ab1c89e0566c35222086766b7374ed
8. Checklist de Diagnóstico — Error "QR no coincide"
| # | Verificación | Herramienta |
|---|---|---|
| A | DigestValue en URL tiene 88 chars (SHA-256) | strlen($digestHex) === 88 |
| B | dFeEmiDE en URL tiene 38 chars | strlen($dFeEmiDE) === 38 |
| C | IdCSC tiene exactamente 4 dígitos con ceros | strlen($idCsc) === 4 |
| D | CSC en DB es el real (no placeholder) | Consultar portal SIFEN → Mis Datos → CSC |
| E | No hay & entre urlParams y CSC en el hash | Revisar concatenación |
| F | No hay trailing & al final de urlParams | Usar implode('&', ...) |
| G | Hex en minúsculas | hash('sha256', ...) es lowercase por defecto en PHP |
| H | Orden de parámetros es el correcto | Ver tabla §3 |
| I | dTotIVA = "0" cuando iTImp ≠ 1 y ≠ 5 | Revisar lógica de impuesto |
| J | RUC receptor incluye guión-DV si iNatRec=1 | "44444401-7" no "44444401" |
9. Causa más probable del error en test
El CSC almacenado en DB es un placeholder ABCD0000000000000000000000000000.
SIFEN recalcula SHA-256(urlParams + CSC_real) y lo compara con cHashQR. Si el CSC es incorrecto, el hash nunca coincide.
Solución:
// Obtener CSC real del portal SIFEN (Mis Datos → Código de Seguridad del Contribuyente)
php artisan tinker --execute="
\$e = \App\Domains\Sifen\Models\SifenEmitter::find(1);
\$e->csc = 'CSC_REAL_DE_32_CHARS_AQUI';
\$e->save();
echo \$e->csc;
"
10. Inserción en el XML (§13.8.4.5)
El & de la URL se debe escapar como & en el XML. PHP lo hace automáticamente al usar createTextNode() en DOMDocument:
// ✅ createTextNode escapa & → & automáticamente
$dCarQrNode->appendChild($dom->createTextNode($urlQr));
// ❌ NO hacer esto (doble escapado):
$dCarQrNode->textContent = htmlspecialchars($urlQr, ENT_XML1);
En el XML firmado el resultado correcto es:
<dCarQR>https://...?nVersion=150&Id=...&cHashQR=...</dCarQR>