Descargar archivos de transacciones

De punta a punta, cómo elegir las transacciones, obtener sus tickets, facturas y XML, y descargarlos verificando su integridad.

Caso de uso

Cada transacción puede tener tickets (fotos o comprobantes no fiscales), facturas en formato imprimible (por ejemplo, PDF) y comprobantes fiscales digitales en XML (por ejemplo, el CFDI en México). Esta receta descarga todos los archivos de un conjunto de transacciones en tres pasos:

  1. Buscar las transacciones con GET /purchases.
  2. Pedir sus archivos con GET /purchases/files, de a 100 transacciones por consulta.
  3. Descargar cada archivo desde su URL de descarga y verificar su hash.

1. Elige las transacciones

Filtra GET /purchases por lo que necesites y guarda el uniqueCode de cada transacción. dateFrom y dateTo filtran por fecha de pago. Para traer las transacciones creadas o modificadas desde cierto momento, usa lastModifiedDateFrom. También puedes filtrar por compañía, comercio, estado y otros campos (ver la referencia de GET /purchases). Recorre todas las páginas, como se explica en Paginación:

curl -g -X GET 'https://{HOST}/api/public/v1/purchases?dateFrom=2026-10-01T00:00:00Z&size=500&page=0&sort[0].property=createdDate&sort[0].direction=DESC' \
  -H 'X-API-KEY: tu_api_key_aqui'

Las fechas van en formato ISO 8601 con zona horaria: Z o un offset como -06:00. En la URL, el + de un offset positivo se escribe %2B. Sin zona, la API responde 400.

Si tu key tiene acceso a varias compañías, agrega companyCode para limitar la búsqueda (ver Uso multi-compañía).

2. Pide los archivos de esas transacciones

Pasa los uniqueCode en purchaseCodes, repitiendo el parámetro, en tandas de hasta 100 transacciones:

curl -X GET 'https://{HOST}/api/public/v1/purchases/files?purchaseCodes=COMPRA_1&purchaseCodes=COMPRA_2&size=100' \
  -H 'X-API-KEY: tu_api_key_aqui'

La respuesta trae un elemento por cada transacción que tiene archivos. Las transacciones sin archivos no aparecen, así que no esperes un elemento por cada código que enviaste:

{
  "_embedded": {
    "purchasesFiles": [
      {
        "purchaseCode": "COMPRA_1",
        "ticketFiles": [
          {
            "uniqueCode": "…",
            "filename": "ticket.png",
            "extension": "png",
            "contentType": "image/png",
            "sizeInBytes": 16175,
            "fileDigest": "9f2c…",
            "_links": { "bytes": { "href": "https://…" } }
          }
        ],
        "invoiceFiles": [],
        "digitalInvoiceFiles": []
      }
    ]
  },
  "page": { "size": 100, "totalElements": 1, "totalPages": 1, "number": 0 }
}
CampoContenido
ticketFilesTickets y comprobantes no fiscales.
invoiceFilesFacturas en formato imprimible (por ejemplo, PDF).
digitalInvoiceFilesComprobantes fiscales digitales en XML.

Las listas vacías pueden venir vacías o no venir: la API omite los campos sin valor. Trata una lista ausente como vacía. Ignora invoiceFile y digitalInvoiceFile (en singular): solo repiten el primer elemento de la lista correspondiente.

Para una sola transacción también puedes usar GET /purchases/{code}/files, que devuelve el mismo objeto sin paginar.

3. Descarga y verifica cada archivo

_links.bytes.href es una URL prefirmada: se descarga con un GET simple, sin X-API-KEY, y vence pocos minutos después de emitida. Descarga cada archivo apenas recibes la respuesta del paso 2 y no guardes la URL para después: si venció, vuelve a pedir los archivos de esa transacción.

curl -o ticket.png 'URL_DE_BYTES_HREF'

fileDigest es el SHA-256 del contenido en hexadecimal. Compáralo con el hash del archivo descargado para confirmar que llegó completo:

sha256sum ticket.png

El mismo fileDigest en dos archivos indica que son el mismo contenido.

La receta completa

// Node.js 18 o superior. Descarga los archivos de las transacciones pagadas desde una fecha.
import { createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';

const HOST = 'https://mx.stage.app.mendel.com';
const headers = { 'X-API-KEY': process.env.MENDEL_API_KEY, Accept: 'application/json' };

async function get(path) {
  const res = await fetch(`${HOST}/api/public/v1${path}`, { headers });
  if (!res.ok) throw new Error(`${res.status} en ${path}`);
  return res.json();
}

// 1. Los uniqueCode de las transacciones, recorriendo todas las páginas.
async function purchaseCodes(filters) {
  const codes = [];
  for (let page = 0, totalPages = 1; page < totalPages; page++) {
    const query = new URLSearchParams({
      ...filters, page, size: 500,
      'sort[0].property': 'createdDate', 'sort[0].direction': 'DESC',
    });
    const body = await get(`/purchases?${query}`);
    codes.push(...(body._embedded?.purchases ?? []).map((p) => p.uniqueCode));
    totalPages = body.page.totalPages;
  }
  return codes;
}

// 2. Sus archivos, en tandas de 100 transacciones.
async function* purchaseFiles(codes) {
  for (let i = 0; i < codes.length; i += 100) {
    const query = new URLSearchParams({ size: 100 });
    for (const code of codes.slice(i, i + 100)) query.append('purchaseCodes', code);
    const body = await get(`/purchases/files?${query}`);
    yield* body._embedded?.purchasesFiles ?? [];
  }
}

// 3. Descarga cada archivo (sin la API key) y verifica su hash.
const codes = await purchaseCodes({ dateFrom: '2026-10-01T00:00:00Z' });
for await (const purchase of purchaseFiles(codes)) {
  const files = [
    ...(purchase.ticketFiles ?? []),
    ...(purchase.invoiceFiles ?? []),
    ...(purchase.digitalInvoiceFiles ?? []),
  ];
  await mkdir(`archivos/${purchase.purchaseCode}`, { recursive: true });
  for (const file of files) {
    const res = await fetch(file._links.bytes.href);
    if (!res.ok) throw new Error(`${res.status} al descargar ${file.uniqueCode}`);
    const bytes = Buffer.from(await res.arrayBuffer());
    const digest = createHash('sha256').update(bytes).digest('hex');
    if (digest !== file.fileDigest) throw new Error(`Hash distinto en ${file.uniqueCode}`);
    await writeFile(`archivos/${purchase.purchaseCode}/${file.uniqueCode}.${file.extension}`, bytes);
  }
}

Nombres de archivo

La receta guarda cada archivo con su uniqueCode y su extension, que son únicos y seguros para el sistema de archivos. filename es el nombre original: puede repetirse entre archivos y contener caracteres no válidos en una ruta.

Problemas frecuentes

SíntomaCausa
La respuesta del paso 2 trae menos transacciones que las que enviasteLas transacciones sin archivos no aparecen.
El paso 2 devuelve una página vacíaNo enviaste purchaseCodes, o ninguna de esas transacciones tiene archivos o es de tus compañías.
La descarga responde 403La URL venció. Vuelve a pedir los archivos de esa transacción.
El hash no coincide con fileDigestLa descarga se cortó. Vuelve a descargar el archivo.
400 en el paso 1Revisa el formato de las fechas (con zona horaria) y el de sort.

En esta página