Created
March 1, 2026 23:44
-
-
Save grundmanise/3f4aa314fe3a1be641143315d072348e to your computer and use it in GitHub Desktop.
Download stripe invoices
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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