Free Stock API With curl and jq: No Key
Call a free stock market API from the shell: install curl and jq on Ubuntu, read the JSON, filter rows with jq, and export clean CSV. No key needed.
You can query a free stock market API with curl and jq from any Ubuntu shell, with no account and no key. Two apt-get lines put both tools in place, one GET request returns JSON, and a short jq expression turns that JSON into a single field, a filtered subset, or a CSV file a spreadsheet opens directly. Every command below runs start to finish in a fresh container with nothing preinstalled beyond apt.
Installing curl and jq on a fresh Ubuntu shell
A clean Ubuntu image ships with neither tool. Refresh the package index, then install both, along with the root certificates an HTTPS request needs:
apt-get update
apt-get install -y --no-install-recommends curl jq ca-certificates
Inside a container you are already root. On a desktop machine, put sudo in front of each line. Check that both landed:
curl --version
jq --version
curl speaks HTTP and writes whatever comes back to standard output. jq is a filter for JSON: it reads JSON on standard input, applies an expression you write, and prints the result. Those two programs cover this entire page. There is no SDK to install and nothing to configure.
How do I call a free stock API with curl?
One GET request, no headers and no token:
curl -sS "https://ai.strasmore.com/api/demo?q=market_holidays"
-s hides the progress meter that would otherwise mix into your output, and -S keeps genuine errors visible. The q parameter names one of the prewritten queries in the public catalog, and the catalog lists its own contents:
curl -sS "https://ai.strasmore.com/api/demo/catalog" | jq -r '.queries[].key'
As of September 2026 that prints thirteen keys, one per line:
spy_qqq
dividend_yield_leaders
upcoming_ex_dividends
short_interest_watch
iv_leaders
recent_ipos
latest_market_news
biggest_movers
upcoming_splits
revenue_leaders
most_active_options
put_call_ratio
market_holidays
Any one of those is a valid value for q. The endpoint and the account tiers above it are documented in the free stock market data API guide.
What does the raw JSON response look like?
Pipe the response through jq . and it arrives indented rather than as one long line. Reading the keys first is quicker than reading the whole document:
curl -sS "https://ai.strasmore.com/api/demo?q=market_holidays" | jq 'keys'
[
"ask_anything",
"columns",
"elapsed",
"key",
"label",
"more",
"nl",
"rows",
"source",
"sql"
]
Four of those carry what you came for. columns is the ordered list of column names. rows is an array of objects, each one keyed by those same column names. sql is the exact statement that produced the rows, so any figure in the response can be recomputed by hand. elapsed is the server side query time in seconds.
Ask for the column list on its own:
curl -sS "https://ai.strasmore.com/api/demo?q=market_holidays" | jq -c '.columns'
["date","name","status","holiday_date"]
A row is an object with those four keys, which means .rows[0].status addresses a single cell and .rows | length counts the rows you were given.
Three jq one-liners worth memorising
Pull one field out of every row
.rows[] emits each row object in turn. Append a field name and jq prints that field from every row:
curl -sS "https://ai.strasmore.com/api/demo?q=market_holidays" | jq -r '.rows[].holiday_date'
-r means raw output: strings print without their surrounding quotes, which is what you want when the next stage of the pipeline is sort or wc -l. Drop -r and you get quoted JSON strings back instead.
Filter rows by a predicate
select keeps only the rows whose expression is true. Half day sessions, for instance:
curl -sS "https://ai.strasmore.com/api/demo?q=market_holidays" \
| jq -r '.rows[] | select(.status == "early-close") | .holiday_date'
To count the survivors without printing them, wrap the filter in brackets so the results collect into one array, then measure it:
curl -sS "https://ai.strasmore.com/api/demo?q=market_holidays" \
| jq '[.rows[] | select(.status == "early-close")] | length'
jq predicates compose the way you would expect. and and or join them, != negates a match, > and < compare numbers, and test("...") runs a regular expression against a string field.
Reshape the result into CSV
@csv takes an array of scalar values and returns one properly quoted CSV line. Feed it the header first, then one array per row, indexing each row object by the column names the response already handed you:
curl -sS "https://ai.strasmore.com/api/demo?q=market_holidays" \
| jq -r '.columns as $c | $c, (.rows[] | [.[$c[]]]) | @csv' > holidays.csv
$c holds the column list. On its own it emits the header line, and [.[$c[]]] pulls the same fields out of each row in that same order. Open holidays.csv in any spreadsheet. The order comes from columns rather than from key order in the JSON, which keeps the file stable even if the response grows a field later.
Writing your own SQL from the shell
The prewritten queries are a starting point. A second endpoint accepts read only SQL directly, and the documented method is GET ?sql=... or a POST carrying {"sql": "..."}. For the GET form, let curl do the escaping with --get and --data-urlencode rather than hand encoding spaces and quotes:
curl -sS --get "https://ai.strasmore.com/api/demo/sql" \
--data-urlencode "sql=SELECT ticker, date, close FROM global_markets.stocks_daily_aggs WHERE ticker = 'KO' ORDER BY date DESC LIMIT 10" \
| jq -c '{row_count, truncated, columns}'
{"row_count":10,"truncated":false,"columns":["ticker","date","close"]}
That is the response shape a script reads. The stored result of the same query shape over a longer window renders as a panel below, with the exact SQL expandable underneath:
| date | close_usd |
|---|---|
| 2026-08-19 | 90.35 |
| 2026-08-20 | 90.5 |
| 2026-08-21 | 91.1 |
| 2026-08-24 | 91.99 |
| 2026-08-25 | 91.64 |
| 2026-08-26 | 90.08 |
| 2026-08-27 | 89.06 |
| 2026-08-28 | 89.66 |
| 2026-08-31 | 88.67 |
| 2026-09-01 | 88 |
| 2026-09-02 | 88.24 |
| 2026-09-03 | 88.81 |
| 2026-09-04 | 88.07 |
| 2026-09-08 | 88.36 |
| 2026-09-09 | 87.55 |
| 2026-09-10 | 87.83 |
| 2026-09-11 | 88.29 |
| 2026-09-14 | 89.35 |
| 2026-09-15 | 88.71 |
| 2026-09-16 | 87.87 |
The exact SQL behind every number
SELECT
date,
close AS close_usd
FROM (
SELECT date, close
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'KO'
ORDER BY date DESC
LIMIT 30
)
ORDER BY dateThe panel illustrates the response rather than a figure to quote: the window rolls forward every session, and a curl call made today returns the sessions current then.
The no key tier is capped at 500 rows per response, a 20 second execution budget, one year of history, and no tick level data. Those caps ship inside the response under .limits, so a script can read them instead of assuming them. Table names are qualified as global_markets.<table>, and the schema endpoint lists every table and its columns:
curl -sS "https://ai.strasmore.com/api/demo/schema" | jq -r '.tables[].table'
curl -sS "https://ai.strasmore.com/api/demo/schema" \
| jq -r '.tables[] | select(.table == "stocks_dividends") | .columns[]'
For a longer worked SQL example, the free SQL API for stock market data walks through screeners, and the ex-dividend calendar built on the free SQL API shows one query kept useful over time.
How does paging work?
There is no page number and no cursor. Paging happens in SQL. Put a deterministic ORDER BY on the query, keep LIMIT at or under the 500 row cap, then advance OFFSET one page at a time:
for off in 0 500 1000; do
curl -sS --get "https://ai.strasmore.com/api/demo/sql" \
--data-urlencode "sql=SELECT ticker, date, close FROM global_markets.stocks_daily_aggs WHERE ticker = 'KO' ORDER BY date DESC LIMIT 500 OFFSET $off" \
| jq -c --argjson off "$off" '{offset: $off, row_count, truncated}'
done
Two fields tell you when to stop. row_count is how many rows came back, and a page returning zero has run past the end of the result. truncated is true when the server cut the answer at the row cap, which is your signal that another page exists. The ORDER BY is not optional: without it the database is free to return rows in any order it likes, and consecutive pages can then repeat rows or skip them silently. Order by a column combination that is unique per row.
What do failures look like?
Failure has more than one shape, and telling them apart matters more than reacting to any of them. Start by capturing the status code and the body separately:
status=$(curl -sS -o response.json -w '%{http_code}' \
--get "https://ai.strasmore.com/api/demo/sql" \
--data-urlencode "sql=SELECT ticker, date, close FROM global_markets.stocks_daily_aggs WHERE ticker = 'KO' ORDER BY date DESC LIMIT 5")
echo "$status"
-o writes the body to a file and -w '%{http_code}' prints the status code and nothing else.
An empty result is a success. The request worked and the query ran; nothing matched it. Ask for a symbol that does not exist and you get a 200 with an empty array:
curl -sS --get "https://ai.strasmore.com/api/demo/sql" \
--data-urlencode "sql=SELECT ticker, date, close FROM global_markets.stocks_daily_aggs WHERE ticker = 'ZZZZNOPE' LIMIT 5" \
| jq -c '{row_count, rows}'
{"row_count":0,"rows":[]}
columns still lists the columns you asked for, which is how you know the statement parsed. The .window object is worth reading whenever a query you expected to match comes back empty: .window.clipped names any table whose history the no key tier trimmed before the query ran, and .window.note spells out the possibilities in a sentence.
curl -sS --get "https://ai.strasmore.com/api/demo/sql" \
--data-urlencode "sql=SELECT ticker, date, close FROM global_markets.stocks_daily_aggs WHERE ticker = 'ZZZZNOPE' LIMIT 5" \
| jq -r '.window.note'
Rejected SQL returns HTTP 400. A typo, a table that does not exist, or a statement the read only gate declines all land here, and the body is not guaranteed to be the JSON envelope above. This is the trap for shell pipelines: curl exits 0 on a 400 unless you ask it not to, so curl ... | jq .rows quietly hands an error page to jq. The flag --fail-with-body, available in curl 7.76 and newer, prints the body and still exits non zero.
A rate limit arrives in the HTTP status line rather than in the JSON body. The code is 429, and a Retry-After header may accompany it with a wait in seconds. Save the headers with -D and look:
curl -sS -D headers.txt -o response.json -w '%{http_code}\n' \
"https://ai.strasmore.com/api/demo?q=market_holidays"
grep -i '^retry-after' headers.txt
grep prints nothing and exits 1 when the header is absent, which is the ordinary case on a single well behaved request.
Asserting the shape, not the values
jq -e sets its own exit status from its last output: 0 when that output is neither false nor null, 1 when it is false. That turns a one liner into a test you can put in a script:
jq -e '(.columns | length) > 0 and .row_count > 0' response.json > /dev/null \
&& echo "shape ok" || echo "shape check failed"
Assert the shape and never a value. A check that requires the keys to be present, the column list to be non empty and row_count above zero keeps passing for years. A check pinned to a particular closing price or a particular date fails on the next session, and the failure tells you nothing about whether the API is healthy.
Full command notes
Every command on this page was run against a fresh Ubuntu container in September 2026 with nothing installed beyond apt, curl, jq and ca-certificates. The --no-install-recommends flag keeps the image small, which is why ca-certificates is named explicitly: without it, an HTTPS request fails certificate verification before it reaches the server. The KO panel is the stored result of the statement printed under it, drawn from daily bars on the free tier, and it rolls forward as new sessions settle. Delayed equity prices sit behind these endpoints, and options greeks and implied volatility are end of day figures, so a response is a record of what has already traded.
FAQ
Do I need an API key to query a free stock API with curl?
No. The demo endpoint answers a plain GET with no headers, no token and no signup, which is what makes it usable from a one line shell command. A free account raises the ceiling to 100 queries a day, still without a card.
How do I convert a JSON API response to CSV with jq?
Use @csv with the -r flag, and drive the column order from the response itself: jq -r '.columns as $c | $c, (.rows[] | [.[$c[]]]) | @csv'. The first line becomes the header, each row follows in the same field order, and @csv handles quoting for values containing commas.
Why does my query return an empty rows array?
An empty rows array with a 200 status means the statement parsed and matched nothing. Read .window.clipped and .window.note first: the free tier reads one year of history, so a date range outside that window returns nothing even when the symbol is covered. A symbol outside US equities and options returns nothing for the same reason.
What are the limits on the free SQL endpoint?
500 rows per response, a 20 second execution budget, one year of history, and no tick level data. Every response carries those numbers under .limits, so a script should read them rather than hardcode them.
Should I filter in SQL or in jq?
Filter in SQL when the filter removes rows, since the 500 row cap applies to what the server sends. Use jq for reshaping what arrived: picking fields, renaming, counting, and writing CSV.
Every response carries the sql field that produced it, so a curl call plus that statement is enough for anyone to reproduce the numbers independently. If a notebook suits you better than a pipeline, the same API in Python covers requests, retries and pandas. To ask these questions in plain English instead of SQL, put them to the Strasmore terminal.