Guides

Save JSON from a cron job or GitHub Action

Save a JSON result from a scheduled job to a stable URL, then read it from a static page or another script. This guide uses jstash JSON Bins for a server report from cron or repository stats from GitHub Actions.

Written by jstash. Updated 18 October 2026.

Create a bin once

A bin is 1 JSON document at its own URL. Create it once, then replace its contents whenever your job has a new result. The URL stays the same.

Sign in with GitHub or Google at the dashboard and create an API key. The Free plan needs no card. The key starts with js_live_…, is shown once and works on every bin in your account. Store it in an environment variable or CI secret. Never commit it or put it in front-end code.

With JSTASH_API_KEY set, create an empty bin containing {}:

curl -H "Authorization: Bearer $JSTASH_API_KEY" -d '{}' https://api.jstash.app/v1/bins

Keep both response fields: id identifies the bin for updates and url is its public read URL on j.jstash.app.

Use an API key for automation. Trial bins created without one are deleted after 7 days unless claimed. If the key is missing, a POST /v1/bins without Authorization doesn’t fail: it creates a public trial bin. That’s why the scripts check for a key before making requests.

Anyone with a public bin’s URL can read it. Don’t put secrets or personal data in it.

Save from a cron job

The example save-report.sh needs bash, curl 7.76 or later and jq. It stops with a clear message if JSTASH_API_KEY or JSTASH_BIN_ID isn’t set.

It uses jq to build JSON containing the host name, a UTC timestamp and disk use percentage. Replace this with your own data.

#!/usr/bin/env bash
# Save a small JSON report to a jstash bin. Run it from cron, CI or by hand.
# Needs: bash, curl 7.76+, jq. Set JSTASH_API_KEY and JSTASH_BIN_ID first.
set -euo pipefail

: "${JSTASH_API_KEY:?Set JSTASH_API_KEY (an API key from https://app.jstash.app)}"
: "${JSTASH_BIN_ID:?Set JSTASH_BIN_ID (the id of the bin to replace)}"
API="${JSTASH_API:-https://api.jstash.app/v1}"

report="$(mktemp)"
trap 'rm -f "$report"' EXIT

# Build the document. Replace this with your own data.
jq -n \
  --arg host "$(uname -n)" \
  --arg checked_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --argjson disk_used_percent "$(df -P / | awk 'NR==2 {gsub("%", "", $5); print $5}')" \
  '{host: $host, checked_at: $checked_at, disk_used_percent: $disk_used_percent}' > "$report"

# PUT replaces the whole document and keeps the same URL. It's safe to retry.
# The header is read from a file descriptor, so the key never appears in `ps`.
curl --fail-with-body --silent --show-error --retry 3 \
  -X PUT \
  -H @<(printf 'Authorization: Bearer %s\n' "$JSTASH_API_KEY") \
  --data-binary @"$report" \
  "$API/bins/$JSTASH_BIN_ID"
echo

The script sends the document to https://api.jstash.app/v1/bins/$JSTASH_BIN_ID. PUT /v1/bins/{id} replaces the whole document and is safe to retry. The script uses curl’s --fail-with-body --retry 3 and exits non-zero on any error, so cron logs or CI show the failure.

Bodies must be JSON. Use curl’s --data-binary @file.json; plain -d @file strips newlines.

Next, configure the environment and schedule:

# ~/.jstash.env (then: chmod 600 ~/.jstash.env)
export JSTASH_API_KEY='js_live_…'
export JSTASH_BIN_ID='your-bin-id'
export PATH="/usr/local/bin:/usr/bin:/bin"  # cron's default PATH is short: add where jq lives

# crontab -e: run every 15 minutes
*/15 * * * * . "$HOME/.jstash.env" && "$HOME/bin/save-report.sh" >> "$HOME/save-report.log" 2>&1

The example stores the key and bin ID in ~/.jstash.env, made readable only by you with chmod 600. Cron starts with a minimal environment and PATH, so the cron line sources that file before running the script.

The job runs every 15 minutes and appends output and errors to ~/save-report.log. Check that log when a report stops changing.

Save from GitHub Actions

For repository stats, use the workflow. Store JSTASH_API_KEY as an Actions secret and JSTASH_BIN_ID as an Actions variable.

gh secret set JSTASH_API_KEY            # prompts for the key
gh variable set JSTASH_BIN_ID --body your-bin-id

The secret command prompts for the key. You can also set both values in Settings → Secrets and variables → Actions.

Place the workflow at .github/workflows/publish-stats.yml:

name: Publish repo stats to jstash

on:
  schedule:
    - cron: '17 * * * *' # hourly at minute 17 (UTC); GitHub can run it late
  workflow_dispatch:

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - name: Build the JSON
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh api "repos/$GITHUB_REPOSITORY" --jq '{
            repo: .full_name,
            stars: .stargazers_count,
            forks: .forks_count,
            open_issues: .open_issues_count,
            pushed_at: .pushed_at
          }' > stats.json
          cat stats.json

      - name: Save it to jstash
        env:
          JSTASH_API_KEY: ${{ secrets.JSTASH_API_KEY }}
          JSTASH_BIN_ID: ${{ vars.JSTASH_BIN_ID }}
        run: |
          # Without a key, POST would make a public trial bin; PUT would fail. Stop early either way.
          if [ -z "$JSTASH_API_KEY" ] || [ -z "$JSTASH_BIN_ID" ]; then
            echo "Add the JSTASH_API_KEY secret and the JSTASH_BIN_ID variable to this repository." >&2
            exit 1
          fi
          curl --fail-with-body --silent --show-error --retry 3 \
            -X PUT \
            -H "Authorization: Bearer $JSTASH_API_KEY" \
            --data-binary @stats.json \
            "https://api.jstash.app/v1/bins/$JSTASH_BIN_ID"

The first step uses gh api with the built-in github.token to build stats.json from the repository’s own details: stars, forks, open issues and last push. The second checks that the secret and variable exist, then saves stats.json to the bin with curl. The workflow uses permissions: contents: read.

It runs hourly at minute 17 and supports workflow_dispatch. Use the Actions tab to run it by hand for the first test.

GitHub schedules use cron syntax and run in UTC by default, with a shortest interval of every 5 minutes. Scheduled runs can be delayed at busy times or dropped under high load. The top of the hour is busiest; an odd minute like 17 helps.

Scheduled workflows run from the default branch only. In public repositories, GitHub automatically disables them after 60 days without repository activity. Use cron on your own server or another scheduler when precise timing matters.

Read it from a static site

Use the public url returned when you created the bin. This page fetches the repository stats and displays stars and open issues:

<!doctype html>
<meta charset="utf-8">
<title>Repo stats</title>
<p><span id="repo">…</span>: <span id="stars">…</span> stars, <span id="issues">…</span> open issues</p>
<script type="module">
  // A public bin's URL, exactly as jstash returned it. No key in the page: reads need none.
  const BIN_URL = 'https://j.jstash.app/your-bin-id';

  const res = await fetch(BIN_URL);
  if (!res.ok) throw new Error(`Could not load stats: HTTP ${res.status}`);
  const stats = await res.json();
  document.querySelector('#repo').textContent = stats.repo;
  document.querySelector('#stars').textContent = stats.stars;
  document.querySelector('#issues').textContent = stats.open_issues;
</script>

Set BIN_URL to your bin’s public URL. The module script checks res.ok before using the response. There is no API key in the page.

Public reads need no key and aren’t metered. They send Access-Control-Allow-Origin: *, so any website can fetch them. Don’t send custom headers to j.jstash.app: CORS preflight isn’t supported.

After a save, servers and scripts get the new copy at once. A browser reading from a website can get the old copy for up to 5 minutes. Allow for that delay when displaying the result.

Keep a history safely

To keep recent runs inside the document, append-run.sh reads /v1/bins/{id}/content with the API key, saves the ETag header, appends an entry with jq and keeps the newest 50.

for attempt in 1 2 3; do
  # Read the current document and its ETag.
  status="$(curl --silent --show-error -w '%{http_code}' \
    -H @<(printf 'Authorization: Bearer %s\n' "$JSTASH_API_KEY") \
    -D "$dir/headers" -o "$dir/current.json" \
    "$API/bins/$JSTASH_BIN_ID/content")"
  if [ "$status" != 200 ]; then
    echo "Read failed with HTTP $status:" >&2; cat "$dir/current.json" >&2; exit 1
  fi
  etag="$(awk 'tolower($1) == "etag:" {sub(/\r$/, ""); print $2}' "$dir/headers")"

  # Start a list if the bin doesn't hold one yet, append, keep the newest 50.
  jq --argjson entry "$entry" \
    '(if type == "array" then . else [] end) + [$entry] | .[-50:]' \
    "$dir/current.json" > "$dir/next.json"

  status="$(curl --silent --show-error -o "$dir/response.json" -w '%{http_code}' \
    -X PUT \
    -H @<(printf 'Authorization: Bearer %s\n' "$JSTASH_API_KEY") \
    -H "If-Match: $etag" \
    --data-binary @"$dir/next.json" \
    "$API/bins/$JSTASH_BIN_ID")"

  case "$status" in
    200) cat "$dir/response.json"; echo; exit 0 ;;
    412) echo "Bin changed since it was read; retrying ($attempt)" >&2
         sleep $((RANDOM % 3 + 1)) ;;
    *) echo "Save failed with HTTP $status:" >&2; cat "$dir/response.json" >&2; exit 1 ;;
  esac
done

It sends the updated document with If-Match and the last ETag. HTTP 412 ETAG_MISMATCH means someone else saved first: read again, reapply the change and retry. The script retries up to 3 times with a short random pause. Without this check, the last write wins. The full script is in the example repo.

This list of runs is part of your document. jstash has no version history: a replaced document can’t be recovered. Keep your own copy of anything that matters.

When the data shouldn’t be public

Private bins require Pro. Set X-Bin-Visibility: private when creating the bin, then read it with the API key via /content:

# Pro: create a private bin (no public URL)
curl -H "Authorization: Bearer $JSTASH_API_KEY" -H "X-Bin-Visibility: private" \
  -d '{}' https://api.jstash.app/v1/bins

# Read it with the API key
curl -H "Authorization: Bearer $JSTASH_API_KEY" https://api.jstash.app/v1/bins/<id>/content

A private bin has no public URL, and visibility can’t be changed later. Keep authenticated reads in a server job or script.

Pro is US$5 a month, billed monthly. It’s open to everyone; subscribe from the dashboard Plan page and cancel any time.

How often you can save

Check both daily limits and monthly allowances when choosing a schedule:

  • Free: 100 bins, 10 MB total, 100 KB per bin, 100 new bins a day and 1,000 updates a day.
  • Pro: 1,000 bins, 1 GB total, 1 MB per bin, 10,000 updates a day and private bins.
  • Each API key: 60 updates each minute. Daily limits reset at 00:00 UTC.
  • Monthly allowances per account: Free has 5,000 writes and 50,000 reads; Pro has 40,000 writes and 400,000 reads.

The monthly read allowance counts reads of private bins with an API key via /content. Reads of public bins aren’t metered, whether by URL or via /content. Monthly allowances will not be enforced before 17 November 2026, and jstash will announce enforcement before it starts. Saving identical content doesn’t count towards the daily update limit but still counts as a monthly write.

Every 15 minutes means 96 saves a day, about 3,000 in a month: it fits Free. Every 5 minutes is approximately 8,900 a month, over Free’s monthly allowance and within Pro’s. Hourly means 24 a day, about 750 a month.

Use a database for many records, queries, search or partial updates. A bin is read and replaced whole.

We ran each script, the cron line and both workflow steps against a local build of the jstash API before publishing. We haven’t yet run the workflow file itself on GitHub-hosted runners.

Get an API key, then use the full example (MIT).

More guides