Schedule a daily JSON to Excel export with cron

Run a daily JSON to Excel export with cron: one idempotency key per run, a download before the link expires, and a size check. Script and crontab line included.

A clock starts a run with one key that passes through an arrow into a spreadsheet grid, with a check mark for the single file produced

A scheduled export needs three things that a one-off export does not: one idempotency key per run, a copy of the file before its link expires, and a failure that shows up in your logs. The script below does all three, and cron runs it once a day.

A daily report fails in quiet ways. A request times out, the job runs again, and you build the file twice. The signed link expires before anyone opens it. A half-finished download gets saved under an .xlsx name, and the report opens with an error. This guide shows a job that is safe to run twice and that fails loudly when something is wrong.

It builds on the JSON to Excel Node.js and Python guide, which explains the payload and the size check. This one covers the schedule and the retries.

The schedule

This crontab line runs the job at 06:30 every day:

30 6 * * * /usr/bin/node --env-file=/opt/exports/.env /opt/exports/export-orders.mjs >> /var/log/orders-export.log 2>&1
  • The file /opt/exports/.env holds JUSTAPI_KEY=.... Keep it readable only by the user that runs cron (chmod 600), and keep the key out of the crontab itself. The --env-file flag needs Node.js 20.6 or later, as the Node.js CLI reference describes.
  • Cron uses the server's time zone. The script names files by UTC date, so set the server to UTC, or change the date logic to match your time zone. The crontab manual lists the fields.
  • Your monitoring reads the exit code. The script exits with 1 on any failure and writes the reason to the log.

The script

Save this as export-orders.mjs. Replace fetchOrders with your own query, and change OUT_DIR to a folder on your server.

import { createHash } from 'node:crypto';
import { mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';

const API = 'https://api.justapi.tech';
const KEY = process.env.JUSTAPI_KEY;
const OUT_DIR = '/var/exports/orders';
const MAX_ATTEMPTS = 3;

class Refused extends Error {} // 4xx: the request is wrong, retrying cannot help

// One key per report date. Every retry of the same run sends the same key and
// the same body, so the API returns the first response instead of building the file again.
function runKey(date) {
  const hex = createHash('sha256').update(`orders-export:${date}`).digest('hex');
  const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); // RFC 4122 variant bits
  return [hex.slice(0, 8), hex.slice(8, 12), '5' + hex.slice(13, 16), variant + hex.slice(17, 20), hex.slice(20, 32)].join('-');
}

// Replace this with your own query. Return one object per row.
async function fetchOrders(date) {
  return [
    { order: '00123', amount: 1250.5, placed: date },
    { order: '00124', amount: 98, placed: date },
  ];
}

function sheetsFor(rows) {
  return [{
    name: 'Orders',
    columns: [
      { header: 'Order', key: 'order', type: 'string' },
      { header: 'Amount', key: 'amount', type: 'number', format: '$#,##0.00' },
      { header: 'Placed', key: 'placed', type: 'date', format: 'yyyy-mm-dd' },
    ],
    rows,
  }];
}

async function postWithRetry(body, key) {
  let lastError;
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    try {
      const res = await fetch(`${API}/v1/xlsx/generate`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': key,
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(120_000),
      });
      if (res.ok) return res.json();
      const text = await res.text();
      if (res.status !== 429 && res.status < 500) throw new Refused(`HTTP ${res.status} ${text}`);
      lastError = new Error(`HTTP ${res.status}`);
    } catch (err) {
      if (err instanceof Refused) throw err;
      lastError = err; // timeout or dropped connection: the same key makes the retry safe
    }
    if (attempt < MAX_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, 5_000 * attempt));
  }
  throw lastError;
}

async function exists(file) {
  return stat(file).then(() => true, () => false);
}

export async function runExport(date = new Date().toISOString().slice(0, 10)) {
  const filename = `orders-${date}.xlsx`;
  const file = path.join(OUT_DIR, filename);
  if (await exists(file)) return { file, skipped: 'already exported' };

  const rows = await fetchOrders(date);
  const body = { sheets: sheetsFor(rows), options: { filename, allowFormulas: false } };
  const { job_id, result } = await postWithRetry(body, runKey(date));
  if (!result?.url) throw new Error(`job ${job_id} returned no download link; check it before retrying`);

  const res = await fetch(result.url); // the link is signed, so no key is sent
  if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
  const bytes = Buffer.from(await res.arrayBuffer());
  if (bytes.length !== result.bytes) {
    throw new Error(`size mismatch: got ${bytes.length} bytes, expected ${result.bytes}`);
  }

  await mkdir(OUT_DIR, { recursive: true });
  await writeFile(file, bytes);
  return { file, job_id, bytes: bytes.length, rows: rows.length };
}

const date = process.argv[2] ?? new Date().toISOString().slice(0, 10);
try {
  console.log(JSON.stringify(await runExport(date)));
} catch (err) {
  console.error(`export ${date} failed: ${err.message}`);
  process.exitCode = 1;
}

Four parts do the work:

  • runKey(date) returns the same UUID-shaped key for the same date. A retry of the same run sends the same key and the same body, so the API returns the first response instead of building the file again. The key comes from the date, so a retry needs no saved state.
  • postWithRetry retries timeouts, dropped connections, 429 and 5xx responses, up to three attempts, with waits of 5 and then 10 seconds. A 4xx stops at once, because the request itself is wrong and sending it again changes nothing.
  • The download check compares the bytes you received with result.bytes, as the earlier guide explains.
  • The skip exits without calling the API when the file for that date already exists. Running cron twice on the same day is harmless.

What the retry does and does not protect

The key covers the body too. If the rows change between two attempts of the same run, the API returns 409 idempotency_conflict, and the script stops. That is intended. A different body under the same key means the run is no longer the same run, so decide what to send before you retry.

Replays return the first response for 24 hours. A replay can come back without a url when the file is almost expired. The script stops and prints the job ID in that case. Check the job, and if you need a new file, create one under a new key.

What we tested

We ran the script against a local stub that follows the response shapes in the reference. The stub drops the first response after it creates the job. The script waited, retried with the same key, and the stub counted one job. A second run for the same date skipped the API. The --env-file flag loaded the key. A wrong key stopped the run at once with exit code 1. The same key with a different body returned 409.

The stub is not the live service. Before you schedule the job, run it once by hand with a real key, open the file and check one identifier, one amount and one date.

Before you schedule it

  1. Run the script by hand for a past date. The file should open, and a second run should skip.
  2. Copy the file to your storage in the same run. The local file is only the first step, because the signed link expires. The retention guide gives the windows for each plan.
  3. Set an alert on exit code 1, or on a missing file at 07:00.

Large exports and other options

If a workbook would pass the sync row limit from the earlier guide, split the export into parts, one per branch or month. Give each part its own key, such as orders-export:2026-11-02:branch-07, so each part replays on its own.

If you do not want the request to wait, mode: "async" returns at once and sends a webhook when the file is ready. It does not raise the row limit. The webhook guide explains how to handle a delivery that arrives twice.