Skip to content

Instantly share code, notes, and snippets.

@grundmanise
Created March 1, 2026 23:44
Show Gist options
  • Select an option

  • Save grundmanise/3f4aa314fe3a1be641143315d072348e to your computer and use it in GitHub Desktop.

Select an option

Save grundmanise/3f4aa314fe3a1be641143315d072348e to your computer and use it in GitHub Desktop.
Download stripe invoices
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Download Stripe invoices as PDFs (the same Stripe-generated PDFs customers get).
Requirements:
- macOS (uses BSD date)
- curl
- jq
Auth:
- Set STRIPE_API_KEY to a Stripe secret key (sk_live_... / sk_test_...) or restricted key (rk_...).
Usage:
./download_stripe_invoices_pdf.sh --month YYYY-MM [--out DIR] [--status STATUS]
./download_stripe_invoices_pdf.sh --from YYYY-MM-DD --to YYYY-MM-DD [--out DIR] [--status STATUS]
Examples:
STRIPE_API_KEY=sk_live_... ./download_stripe_invoices_pdf.sh --month 2026-02
STRIPE_API_KEY=rk_live_... ./download_stripe_invoices_pdf.sh --from 2026-02-01 --to 2026-02-29 --out ./invoices
Options:
--api-key KEY (optional) provide API key via flag instead of STRIPE_API_KEY
--month YYYY-MM download invoices created in that UTC month
--from YYYY-MM-DD start date (UTC, inclusive)
--to YYYY-MM-DD end date (UTC, inclusive)
--status STATUS Stripe invoice status filter (default: paid)
--out DIR output directory (default: ./stripe-invoices)
--sleep SECONDS sleep between PDF downloads (default: 0)
-h, --help show this help
EOF
}
die() {
echo "Error: $*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
# --- deps ---
need_cmd curl
need_cmd jq
need_cmd date
API_KEY="${STRIPE_API_KEY:-}"
MONTH=""
FROM=""
TO=""
# Default to downloading only paid invoices.
STATUS="paid"
OUT_DIR="./stripe-invoices"
SLEEP_SECS="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key)
API_KEY="$2"; shift 2 ;;
--month)
MONTH="$2"; shift 2 ;;
--from)
FROM="$2"; shift 2 ;;
--to)
TO="$2"; shift 2 ;;
--status)
STATUS="$2"; shift 2 ;;
--out)
OUT_DIR="$2"; shift 2 ;;
--sleep)
SLEEP_SECS="$2"; shift 2 ;;
-h|--help)
usage; exit 0 ;;
*)
die "Unknown argument: $1 (use --help)" ;;
esac
done
[[ -n "$API_KEY" ]] || die "Set STRIPE_API_KEY or pass --api-key"
# Helper function to check if a year is a leap year
is_leap_year() {
local year=$1
if (( year % 400 == 0 )); then
return 0
elif (( year % 100 == 0 )); then
return 1
elif (( year % 4 == 0 )); then
return 0
else
return 1
fi
}
# Helper function to get the last day of a month
days_in_month() {
local year=$1
local month=$2
case $month in
1|3|5|7|8|10|12) echo 31 ;;
4|6|9|11) echo 30 ;;
2)
if is_leap_year "$year"; then
echo 29
else
echo 28
fi
;;
esac
}
# Compute FROM/TO from --month if provided
if [[ -n "$MONTH" ]]; then
[[ -z "$FROM" && -z "$TO" ]] || die "Use either --month OR --from/--to (not both)"
[[ "$MONTH" =~ ^[0-9]{4}-[0-9]{2}$ ]] || die "--month must be in YYYY-MM format"
FROM="${MONTH}-01"
# Calculate last day of the month
year="${MONTH:0:4}"
month="${MONTH:5:2}"
# Remove leading zero for arithmetic
month_num=$((10#$month))
year_num=$((10#$year))
last_day=$(days_in_month "$year_num" "$month_num")
TO="${MONTH}-${last_day}"
fi
[[ -n "$FROM" && -n "$TO" ]] || die "Provide either --month YYYY-MM or both --from YYYY-MM-DD and --to YYYY-MM-DD"
# Parse dates as UTC 00:00:00
# Use explicit format with time component set to midnight UTC
from_ts=$(date -j -u -f "%Y-%m-%d %H:%M:%S" "$FROM 00:00:00" "+%s") || die "Invalid --from date: $FROM"
# End date inclusive -> 23:59:59 UTC
to_midnight=$(date -j -u -f "%Y-%m-%d %H:%M:%S" "$TO 23:59:59" "+%s") || die "Invalid --to date: $TO"
end_ts=$to_midnight
mkdir -p "$OUT_DIR"
manifest="$OUT_DIR/manifest.tsv"
: > "$manifest"
# Stripe list endpoint pagination uses starting_after
starting_after=""
page=0
fetch_page() {
local starting_after_arg="$1"
# Use -G + --data-urlencode so query params are encoded correctly (created[gte], etc.)
# Auth uses basic auth style: -u "KEY:".
if [[ -n "$starting_after_arg" ]]; then
curl -sS -G "https://api.stripe.com/v1/invoices" \
-u "${API_KEY}:" \
--data-urlencode "limit=100" \
--data-urlencode "created[gte]=${from_ts}" \
--data-urlencode "created[lte]=${end_ts}" \
${STATUS:+--data-urlencode "status=${STATUS}"} \
--data-urlencode "starting_after=${starting_after_arg}"
else
curl -sS -G "https://api.stripe.com/v1/invoices" \
-u "${API_KEY}:" \
--data-urlencode "limit=100" \
--data-urlencode "created[gte]=${from_ts}" \
--data-urlencode "created[lte]=${end_ts}" \
${STATUS:+--data-urlencode "status=${STATUS}"}
fi
}
printf "Downloading Stripe invoices created between %s and %s (UTC)\n" "$FROM" "$TO"
printf "Timestamps: from_ts=%s, end_ts=%s\n" "$from_ts" "$end_ts"
[[ -n "$STATUS" ]] && printf "Filtering by status: %s\n" "$STATUS"
printf "Output dir: %s\n\n" "$OUT_DIR"
while :; do
page=$((page + 1))
json=$(fetch_page "$starting_after")
# Basic error handling: Stripe errors have an "error" object.
if echo "$json" | jq -e '.error? != null' >/dev/null; then
msg=$(echo "$json" | jq -r '.error.message // "Unknown Stripe error"')
die "$msg"
fi
count=$(echo "$json" | jq '.data | length')
printf "Page %d: %d invoices\n" "$page" "$count"
# Write manifest + download PDFs
# Columns: invoice_id, created_utc, number, status, invoice_pdf_url, saved_path
while IFS=$'\t' read -r inv_id created number status pdf_url; do
# created is unix seconds
created_date=$(date -u -r "$created" "+%Y-%m-%d")
outfile="$OUT_DIR/${created_date}_${inv_id}.pdf"
if [[ -z "$pdf_url" || "$pdf_url" == "null" ]]; then
printf " - %s (%s): no invoice_pdf (likely draft/unfinalized). Skipping.\n" "$inv_id" "$status" >&2
printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$inv_id" "$created_date" "$number" "$status" "" "" >> "$manifest"
continue
fi
if [[ -f "$outfile" && -s "$outfile" ]]; then
printf " - %s already exists, skipping (%s)\n" "$inv_id" "$outfile"
printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$inv_id" "$created_date" "$number" "$status" "$pdf_url" "$outfile" >> "$manifest"
continue
fi
printf " - downloading %s -> %s\n" "$inv_id" "$outfile"
curl -sS -L "$pdf_url" -o "$outfile"
if [[ ! -s "$outfile" ]]; then
printf " ! download resulted in empty file for %s\n" "$inv_id" >&2
fi
printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$inv_id" "$created_date" "$number" "$status" "$pdf_url" "$outfile" >> "$manifest"
if [[ "$SLEEP_SECS" != "0" ]]; then
sleep "$SLEEP_SECS"
fi
done < <(
echo "$json" | jq -r '.data[] | [.id, (.created|tostring), (.number // ""), (.status // ""), (.invoice_pdf // "")] | @tsv'
)
has_more=$(echo "$json" | jq -r '.has_more')
if [[ "$has_more" == "true" ]]; then
starting_after=$(echo "$json" | jq -r '.data[-1].id')
else
break
fi
echo
done
echo
printf "Done. PDFs saved in: %s\n" "$OUT_DIR"
printf "Manifest written to: %s\n" "$manifest"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment