Visión General
El CDR (Constancia de Recepción) es la respuesta de SUNAT que confirma que un documento electrónico fue recibido y validado. Contiene el código de respuesta y observaciones.
Cada documento exitosamente enviado a SUNAT genera un CDR en formato ZIP que debe almacenarse para auditorías.
¿Qué es el CDR?
El CDR es un archivo XML firmado digitalmente por SUNAT que contiene:
Código de respuesta : 0 = Aceptado, otros = Rechazado/Observado
Descripción : Mensaje de SUNAT
Observaciones : Advertencias o errores no críticos
Hash del documento : Para verificar integridad
Fecha y hora de procesamiento
Estructura del CDR
El CDR se entrega como archivo ZIP:
R-{RUC}-{TipoDoc}-{Serie}-{Numero}.zip
Ejemplos:
R-20612706702-01-F001-00000123.zip # CDR de factura
R-20612706702-03-B001-00004567.zip # CDR de boleta
R-20612706702-07-FC01-00000012.zip # CDR de nota de crédito
Dentro del ZIP hay un archivo XML:
R-{RUC}-{TipoDoc}-{Serie}-{Numero}.xml
Contenido del XML
<? xml version = "1.0" encoding = "UTF-8" ?>
< ApplicationResponse xmlns = "urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2" >
< cbc:ResponseCode > 0 </ cbc:ResponseCode >
< cbc:ResponseDate > 2026-03-06 </ cbc:ResponseDate >
< cbc:ResponseTime > 14:30:25 </ cbc:ResponseTime >
< cac:DocumentResponse >
< cac:Response >
< cbc:ResponseCode > 0 </ cbc:ResponseCode >
< cbc:Description > La Factura numero F001-00000123, ha sido aceptada </ cbc:Description >
</ cac:Response >
</ cac:DocumentResponse >
< cac:SignatureInformation >
<!-- Firma digital de SUNAT -->
</ cac:SignatureInformation >
</ ApplicationResponse >
Procesamiento del CDR
En Envío de Facturas
if ( $result -> isSuccess ()) {
$cdr = $result -> getCdrResponse ();
$cdrZip = $result -> getCdrZip ();
// Guardar CDR en storage
$cdrDir = storage_path ( "app/sunat/cdr/{ $ruc }" );
if ( ! is_dir ( $cdrDir )) {
mkdir ( $cdrDir , 0755 , true );
}
file_put_contents ( "{ $cdrDir }/R-{ $nombreArchivo }.zip" , $cdrZip );
// Actualizar venta con información del CDR
$venta -> update ([
'estado_sunat' => '1' , // Aceptado
'cdr_url' => "sunat/cdr/{ $ruc }/R-{ $nombreArchivo }.zip" ,
'codigo_sunat' => $cdr -> getCode (), // "0" para aceptado
'mensaje_sunat' => $cdr -> getDescription (),
]);
return [
'success' => true ,
'codigo' => $cdr -> getCode (),
'mensaje' => $cdr -> getDescription (),
'cdr_url' => "sunat/cdr/{ $ruc }/R-{ $nombreArchivo }.zip" ,
];
}
Métodos del Objeto CDR
El objeto $cdr (CdrResponse) proporciona:
$cdr -> getCode (); // Código de respuesta: "0", "2000", etc.
$cdr -> getDescription (); // Descripción: "La Factura ... ha sido aceptada"
$cdr -> getNotes (); // Array de observaciones (puede ser null)
$cdr -> getId (); // ID del documento
$cdr -> getReference (); // Referencia al documento original
Códigos de Respuesta
Códigos de Éxito
Código 0 Aceptado El documento fue aceptado sin observaciones
Código 0001-0999 Aceptado con Observaciones Documento aceptado pero con advertencias menores
Códigos de Advertencia (2000-2999)
Documento aceptado pero con observaciones que deben corregirse:
Código Descripción 2000 Número de RUC del receptor no existe 2001 DNI del receptor no existe 2002 Número de documento duplicado 2010 El número del documento ya existe 2324 La serie no corresponde al tipo de comprobante
Los códigos 2XXX indican que el documento fue aceptado, pero hay inconsistencias que SUNAT registra para auditoría.
Códigos de Rechazo (>= 4000)
Documento rechazado, no válido:
Código Descripción Solución 1033 El documento ya fue enviado previamente No reenviar 2017 El documento no cumple con el formato Revisar estructura XML 2200 La firma digital no corresponde Renovar certificado 2800 El comprobante no cumple con el grupo de ítems Revisar detalles 4000 Error interno de SUNAT Reintentar más tarde
Para documentos asíncronos (guías, resumen diario, comunicación de baja):
Consultar Estado
public function consultarTicket ( Empresa $empresa , string $ticket ) : array
{
$see = $this -> getSee ( $empresa );
$result = $see -> getStatus ( $ticket );
$ruc = $this -> getRuc ( $empresa );
if ( $result -> isSuccess ()) {
$cdr = $result -> getCdrResponse ();
$cdrZip = $result -> getCdrZip ();
if ( $cdrZip ) {
$cdrDir = storage_path ( "app/sunat/cdr/{ $ruc }" );
if ( ! is_dir ( $cdrDir )) {
mkdir ( $cdrDir , 0755 , true );
}
file_put_contents ( "{ $cdrDir }/R-ticket-{ $ticket }.zip" , $cdrZip );
}
return [
'success' => true ,
'codigo' => $cdr -> getCode (),
'mensaje' => $cdr -> getDescription (),
'notas' => $cdr -> getNotes () ?? [],
];
}
$code = $result -> getCode ();
if ( $code === '98' ) {
return [
'success' => true ,
'codigo' => '98' ,
'mensaje' => 'En proceso. Intente nuevamente en unos segundos.' ,
'en_proceso' => true ,
];
}
$error = $result -> getError ();
return [
'success' => false ,
'codigo' => $error ? $error -> getCode () : $code ,
'message' => $error ? $error -> getMessage () : 'Error desconocido' ,
];
}
Descarga de CDR
Endpoint de Descarga
Route :: get ( '/descargar-cdr/{id}' , [ VentasController :: class , 'descargarCdr' ])
-> middleware ([ 'auth' , 'token.from.query' ])
-> name ( 'descargar.cdr' );
Controlador
public function descargarCdr ( $id )
{
$venta = Venta :: findOrFail ( $id );
// Verificar permisos
if ( $venta -> id_empresa !== auth () -> user () -> id_empresa ) {
abort ( 403 , 'No autorizado' );
}
if ( ! $venta -> cdr_url ) {
abort ( 404 , 'CDR no disponible' );
}
$cdrPath = storage_path ( "app/{ $venta -> cdr_url }" );
if ( ! file_exists ( $cdrPath )) {
abort ( 404 , 'Archivo CDR no encontrado' );
}
return response () -> download (
$cdrPath ,
"R-{ $venta -> serie }-{ $venta -> numero }.zip" ,
[ 'Content-Type' => 'application/zip' ]
);
}
Desde el Frontend
import { baseUrl } from '@/lib/baseUrl' ;
const descargarCDR = ( ventaId ) => {
const token = localStorage . getItem ( 'auth_token' );
const url = baseUrl ( `/descargar-cdr/ ${ ventaId } ?token= ${ token } ` );
window . open ( url , '_blank' );
};
El hash se utiliza para validar la integridad del documento:
private function getHashFromXml ( string $xml ) : ? string
{
// Usar clase de Greenter si está disponible
if ( class_exists ( \Greenter\Report\ XmlUtils :: class )) {
return ( new \Greenter\Report\ XmlUtils ()) -> getHashSign ( $xml );
}
// Extraer manualmente del XML
preg_match ( '/<ds:DigestValue>([^<] + )< \/ ds:DigestValue>/' , $xml , $matches );
return $matches [ 1 ] ?? null ;
}
El hash se almacena en la tabla ventas como hash_cpe.
Validación del CDR
Verificar Firma Digital
El CDR está firmado digitalmente por SUNAT. Para validar:
use Greenter\Report\ XmlUtils ;
$cdrPath = storage_path ( "app/sunat/cdr/{ $ruc }/R-{ $nombreArchivo }.zip" );
// Extraer XML del ZIP
$zip = new \ZipArchive ();
$zip -> open ( $cdrPath );
$xmlContent = $zip -> getFromIndex ( 0 );
$zip -> close ();
// Validar firma
$xmlUtils = new XmlUtils ();
$isValid = $xmlUtils -> verifyXmlSignature ( $xmlContent );
if ( $isValid ) {
echo "Firma digital válida" ;
} else {
echo "Firma digital inválida o CDR adulterado" ;
}
Si la firma digital del CDR no es válida, el archivo puede haber sido modificado después de su emisión por SUNAT.
Consulta CDR en Portal SUNAT
SUNAT permite consultar el estado de documentos manualmente:
Ir a: https://www.sunat.gob.pe/ol-ti-itconsvalicpe/ConsValiCpe.htm
Ingresar:
RUC del emisor
Tipo de documento
Serie y número
Fecha de emisión
Monto total
Ver estado y descargar CDR
Esta consulta es útil para verificar documentos cuando el CDR no se guardó correctamente en el sistema.
Regeneración de CDR
Si el CDR se perdió, se puede volver a consultar:
public function regenerarCdr ( $id )
{
$venta = Venta :: findOrFail ( $id );
if ( $venta -> estado_sunat !== '1' ) {
return [ 'success' => false , 'message' => 'Solo se pueden regenerar CDR de documentos aceptados' ];
}
$empresa = $venta -> empresa ;
$ruc = $empresa -> modo !== 'production'
? config ( 'sunat.beta.ruc' )
: $empresa -> ruc ;
$xmlPath = storage_path ( "app/{ $venta -> xml_url }" );
if ( ! file_exists ( $xmlPath )) {
return [ 'success' => false , 'message' => 'XML no encontrado' ];
}
$xmlContent = file_get_contents ( $xmlPath );
$nombreArchivo = pathinfo ( $xmlPath , PATHINFO_FILENAME );
$see = app ( SunatService :: class ) -> getSee ( $empresa );
// Reenviar (SUNAT retorna el CDR aunque el documento ya exista)
$result = $see -> sendXml ( Invoice :: class , $nombreArchivo , $xmlContent );
if ( $result -> isSuccess ()) {
$cdrZip = $result -> getCdrZip ();
$cdrDir = storage_path ( "app/sunat/cdr/{ $ruc }" );
if ( ! is_dir ( $cdrDir )) {
mkdir ( $cdrDir , 0755 , true );
}
file_put_contents ( "{ $cdrDir }/R-{ $nombreArchivo }.zip" , $cdrZip );
$venta -> update ([
'cdr_url' => "sunat/cdr/{ $ruc }/R-{ $nombreArchivo }.zip" ,
]);
return [
'success' => true ,
'message' => 'CDR regenerado exitosamente' ,
];
}
return [
'success' => false ,
'message' => 'Error al regenerar CDR' ,
];
}
Almacenamiento y Backup
Estructura de Directorios
storage/app/sunat/
├── xml/
│ └── 20612706702/
│ ├── 20612706702-01-F001-00000123.xml
│ └── 20612706702-03-B001-00000045.xml
├── cdr/
│ └── 20612706702/
│ ├── R-20612706702-01-F001-00000123.zip
│ ├── R-20612706702-03-B001-00000045.zip
│ └── R-ticket-ABC123.zip
└── certificados/
└── 20612706702-cert.pem
Backup Automático
// app/Console/Commands/BackupSunatFiles.php
namespace App\Console\Commands ;
use Illuminate\Console\ Command ;
use Illuminate\Support\Facades\ Storage ;
class BackupSunatFiles extends Command
{
protected $signature = 'sunat:backup' ;
protected $description = 'Backup de archivos XML y CDR' ;
public function handle ()
{
$fecha = now () -> format ( 'Y-m-d' );
$backupPath = "backups/sunat-{ $fecha }.zip" ;
$zip = new \ZipArchive ();
$zipPath = storage_path ( "app/{ $backupPath }" );
if ( $zip -> open ( $zipPath , \ZipArchive :: CREATE | \ZipArchive :: OVERWRITE )) {
// Añadir todos los archivos SUNAT
$sunatPath = storage_path ( 'app/sunat' );
$this -> addDirectoryToZip ( $zip , $sunatPath , 'sunat/' );
$zip -> close ();
$this -> info ( "Backup creado: { $backupPath }" );
// Opcional: Subir a S3, Google Drive, etc.
// Storage::disk('s3')->put($backupPath, file_get_contents($zipPath));
} else {
$this -> error ( 'Error al crear backup' );
}
}
private function addDirectoryToZip ( $zip , $dir , $zipPath )
{
$files = new \RecursiveIteratorIterator (
new \RecursiveDirectoryIterator ( $dir ),
\RecursiveIteratorIterator :: LEAVES_ONLY
);
foreach ( $files as $file ) {
if ( ! $file -> isDir ()) {
$filePath = $file -> getRealPath ();
$relativePath = $zipPath . substr ( $filePath , strlen ( $dir ) + 1 );
$zip -> addFile ( $filePath , $relativePath );
}
}
}
}
Programar en app/Console/Kernel.php:
protected function schedule ( Schedule $schedule )
{
// Backup diario a las 2 AM
$schedule -> command ( 'sunat:backup' ) -> dailyAt ( '02:00' );
}
Vista de CDR en el Frontend
import { DownloadIcon , CheckCircleIcon , ExclamationCircleIcon } from 'lucide-react' ;
const CdrStatus = ({ venta }) => {
const getStatusIcon = () => {
if ( ! venta . codigo_sunat ) return null ;
const codigo = parseInt ( venta . codigo_sunat );
if ( codigo === 0 ) {
return < CheckCircleIcon className = "text-green-500" /> ;
} else if ( codigo < 4000 ) {
return < ExclamationCircleIcon className = "text-yellow-500" /> ;
} else {
return < XCircleIcon className = "text-red-500" /> ;
}
};
return (
< div className = "flex items-center gap-2" >
{ getStatusIcon () }
< div >
< p className = "text-sm font-medium" >
Código: { venta . codigo_sunat || 'N/A' }
</ p >
< p className = "text-xs text-gray-600" >
{ venta . mensaje_sunat || 'Sin respuesta' }
</ p >
</ div >
{ venta . cdr_url && (
< button
onClick = { () => descargarCDR ( venta . id_venta ) }
className = "ml-auto btn btn-sm btn-ghost"
>
< DownloadIcon className = "w-4 h-4" />
Descargar CDR
</ button >
) }
</ div >
);
};
Observaciones del CDR
Algunas respuestas incluyen observaciones adicionales:
$notas = $cdr -> getNotes ();
if ( $notas && count ( $notas ) > 0 ) {
foreach ( $notas as $nota ) {
Log :: warning ( 'Observación SUNAT' , [
'venta' => $venta -> id_venta ,
'observacion' => $nota ,
]);
}
}
Ejemplo de observaciones:
“El número de RUC del receptor no existe en el padrón”
“El documento de identidad del receptor no existe”
“La serie y número del comprobante ya fue informado anteriormente”
Troubleshooting
Causa : Permisos de escritura incorrectos en storage/app/sunat/cdr/.Solución :chmod -R 775 storage/app/sunat/cdr
chown -R www-data:www-data storage/app/sunat/cdr
Causa : Descarga incompleta o error de red.Solución : Usar el método regenerarCdr() para volver a obtener el CDR de SUNAT.
Causa : La serie no está registrada en SUNAT.Solución : Ingresar al portal SUNAT y dar de alta la serie antes de usarla.
No se puede descargar CDR desde frontend
Causa : Token de autenticación no se pasa correctamente.Solución : Usar middleware TokenFromQuery que acepta ?token= en la URL.
Retención de CDR
Los CDR deben conservarse por el plazo de prescripción tributaria (4 años según legislación peruana) para auditorías de SUNAT.
Política de Retención
// Eliminar CDR de más de 5 años
public function limpiarCdrAntiguos ()
{
$fechaLimite = now () -> subYears ( 5 );
$ventasAntiguas = Venta :: where ( 'fecha_emision' , '<' , $fechaLimite )
-> whereNotNull ( 'cdr_url' )
-> get ();
foreach ( $ventasAntiguas as $venta ) {
$cdrPath = storage_path ( "app/{ $venta -> cdr_url }" );
if ( file_exists ( $cdrPath )) {
unlink ( $cdrPath );
$venta -> update ([ 'cdr_url' => null ]);
}
}
return $ventasAntiguas -> count ();
}