<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[jstash]]></title><description><![CDATA[jstash]]></description><link>https://jstash.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 20:21:51 GMT</lastBuildDate><atom:link href="https://jstash.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Save JSON from a cron job or GitHub Action]]></title><description><![CDATA[Save JSON from a cron job or GitHub Action to a stable URL with jstash. Set up writes, read from a static site and check limits.]]></description><link>https://jstash.hashnode.dev/save-json-from-a-cron-job-or-github-action</link><guid isPermaLink="true">https://jstash.hashnode.dev/save-json-from-a-cron-job-or-github-action</guid><category><![CDATA[github-actions]]></category><category><![CDATA[json]]></category><category><![CDATA[Bash]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[jstash]]></dc:creator><pubDate>Sun, 18 Oct 2026 22:11:04 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<h2>Create a bin once</h2>
<p>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.</p>
<p>Sign in with GitHub or Google at <a href="https://app.jstash.app/?utm_source=hashnode.com&amp;utm_medium=article&amp;utm_campaign=hn1-cron">the dashboard</a> and create an API key. The Free plan needs no card. The key starts with <code>js_live_…</code>, 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.</p>
<p>With <code>JSTASH_API_KEY</code> set, create an empty bin containing <code>{}</code>:</p>
<pre><code class="language-sh">curl -H "Authorization: Bearer $JSTASH_API_KEY" -d '{}' https://api.jstash.app/v1/bins
</code></pre>
<p>Keep both response fields: <code>id</code> identifies the bin for updates and <code>url</code> is its public read URL on <code>j.jstash.app</code>.</p>
<p>Use an API key for automation. Trial bins created without one are deleted after 7 days unless claimed. If the key is missing, a <code>POST /v1/bins</code> without <code>Authorization</code> doesn't fail: it creates a public trial bin. That's why the scripts check for a key before making requests.</p>
<p>Anyone with a public bin's URL can read it. Don't put secrets or personal data in it.</p>
<h2>Save from a cron job</h2>
<p>The example <code>save-report.sh</code> needs bash, curl 7.76 or later and jq. It stops with a clear message if <code>JSTASH_API_KEY</code> or <code>JSTASH_BIN_ID</code> isn't set.</p>
<p>It uses jq to build JSON containing the host name, a UTC timestamp and disk use percentage. Replace this with your own data.</p>
<pre><code class="language-bash">#!/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}' &gt; "$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 @&lt;(printf 'Authorization: Bearer %s\n' "$JSTASH_API_KEY") \
  --data-binary @"$report" \
  "$API/bins/$JSTASH_BIN_ID"
echo
</code></pre>
<p>The script sends the document to <code>https://api.jstash.app/v1/bins/$JSTASH_BIN_ID</code>. <code>PUT /v1/bins/{id}</code> replaces the whole document and is safe to retry. The script uses curl's <code>--fail-with-body --retry 3</code> and exits non-zero on any error, so cron logs or CI show the failure.</p>
<p>Bodies must be JSON. Use curl's <code>--data-binary @file.json</code>; plain <code>-d @file</code> strips newlines.</p>
<p>Next, configure the environment and schedule:</p>
<pre><code class="language-sh"># ~/.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" &amp;&amp; "$HOME/bin/save-report.sh" &gt;&gt; "$HOME/save-report.log" 2&gt;&amp;1
</code></pre>
<p>The example stores the key and bin ID in <code>~/.jstash.env</code>, made readable only by you with <code>chmod 600</code>. Cron starts with a minimal environment and <code>PATH</code>, so the cron line sources that file before running the script.</p>
<p>The job runs every 15 minutes and appends output and errors to <code>~/save-report.log</code>. Check that log when a report stops changing.</p>
<h2>Save from GitHub Actions</h2>
<p>For repository stats, use the workflow. Store <code>JSTASH_API_KEY</code> as an Actions secret and <code>JSTASH_BIN_ID</code> as an Actions variable.</p>
<pre><code class="language-sh">gh secret set JSTASH_API_KEY            # prompts for the key
gh variable set JSTASH_BIN_ID --body your-bin-id
</code></pre>
<p>The secret command prompts for the key. You can also set both values in Settings → Secrets and variables → Actions.</p>
<p>Place the workflow at <code>.github/workflows/publish-stats.yml</code>:</p>
<pre><code class="language-yaml">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
          }' &gt; 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." &gt;&amp;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"
</code></pre>
<p>The first step uses <code>gh api</code> with the built-in <code>github.token</code> to build <code>stats.json</code> from the repository's own details: stars, forks, open issues and last push. The second checks that the secret and variable exist, then saves <code>stats.json</code> to the bin with curl. The workflow uses <code>permissions: contents: read</code>.</p>
<p>It runs hourly at minute 17 and supports <code>workflow_dispatch</code>. Use the Actions tab to run it by hand for the first test.</p>
<p>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.</p>
<p>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.</p>
<h2>Read it from a static site</h2>
<p>Use the public <code>url</code> returned when you created the bin. This page fetches the repository stats and displays stars and open issues:</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;meta charset="utf-8"&gt;
&lt;title&gt;Repo stats&lt;/title&gt;
&lt;p&gt;&lt;span id="repo"&gt;…&lt;/span&gt;: &lt;span id="stars"&gt;…&lt;/span&gt; stars, &lt;span id="issues"&gt;…&lt;/span&gt; open issues&lt;/p&gt;
&lt;script type="module"&gt;
  // 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;
&lt;/script&gt;
</code></pre>
<p>Set <code>BIN_URL</code> to your bin's public URL. The module script checks <code>res.ok</code> before using the response. There is no API key in the page.</p>
<p>Public reads need no key and aren't metered. They send <code>Access-Control-Allow-Origin: *</code>, so any website can fetch them. Don't send custom headers to <code>j.jstash.app</code>: CORS preflight isn't supported.</p>
<p>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.</p>
<h2>Keep a history safely</h2>
<p>To keep recent runs inside the document, <code>append-run.sh</code> reads <code>/v1/bins/{id}/content</code> with the API key, saves the <code>ETag</code> header, appends an entry with jq and keeps the newest 50.</p>
<pre><code class="language-bash">for attempt in 1 2 3; do
  # Read the current document and its ETag.
  status="$(curl --silent --show-error -w '%{http_code}' \
    -H @&lt;(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:" &gt;&amp;2; cat "$dir/current.json" &gt;&amp;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" &gt; "$dir/next.json"

  status="$(curl --silent --show-error -o "$dir/response.json" -w '%{http_code}' \
    -X PUT \
    -H @&lt;(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)" &gt;&amp;2
         sleep $((RANDOM % 3 + 1)) ;;
    *) echo "Save failed with HTTP $status:" &gt;&amp;2; cat "$dir/response.json" &gt;&amp;2; exit 1 ;;
  esac
done
</code></pre>
<p>It sends the updated document with <code>If-Match</code> and the last ETag. HTTP <code>412 ETAG_MISMATCH</code> 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.</p>
<p>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.</p>
<h2>When the data shouldn't be public</h2>
<p>Private bins require Pro. Set <code>X-Bin-Visibility: private</code> when creating the bin, then read it with the API key via <code>/content</code>:</p>
<pre><code class="language-sh"># 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/&lt;id&gt;/content
</code></pre>
<p>A private bin has no public URL, and visibility can't be changed later. Keep authenticated reads in a server job or script.</p>
<p>Pro is US$5 a month, billed monthly. It's open to everyone; subscribe from the dashboard Plan page and cancel any time.</p>
<h2>How often you can save</h2>
<p>Check both daily limits and monthly allowances when choosing a schedule:</p>
<ul>
<li><p>Free: 100 bins, 10 MB total, 100 KB per bin, 100 new bins a day and 1,000 updates a day.</p>
</li>
<li><p>Pro: 1,000 bins, 1 GB total, 1 MB per bin, 10,000 updates a day and private bins.</p>
</li>
<li><p>Each API key: 60 updates each minute. Daily limits reset at 00:00 UTC.</p>
</li>
<li><p>Monthly allowances per account: Free has 5,000 writes and 50,000 reads; Pro has 40,000 writes and 400,000 reads.</p>
</li>
</ul>
<p>The monthly read allowance counts reads of private bins with an API key via <code>/content</code>. Reads of public bins aren't metered, whether by URL or via <code>/content</code>. 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.</p>
<p>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.</p>
<p>Use a database for many records, queries, search or partial updates. A bin is read and replaced whole.</p>
<p>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.</p>
<p><a href="https://app.jstash.app/?utm_source=hashnode.com&amp;utm_medium=article&amp;utm_campaign=hn1-cron">Get an API key</a>, then use <a href="https://github.com/jstash-app/jstash-examples/tree/main/examples/bins-cron-github-actions">the full example (MIT)</a>.</p>
<p><em>Also on</em> <a href="https://jstash.app/guides/save-json-cron-github-actions/?utm_source=hashnode.com&amp;utm_medium=article&amp;utm_campaign=hn1-cron"><em>jstash.app</em></a><em>.</em> OpenAI Codex wrote this jstash post without human editing. Claude, jstash’s AI assistant, assembled code, checked facts against jstash’s code and docs, and published. A separate AI reviewed code and claims. Code was tested.</p>
]]></content:encoded></item></channel></rss>