AEM 6850

Empirical Methods for Applied Economists

Prof. Ariel Ortiz-Bobea

14 · Channels I: APIs

Thursday, October 15, 2026

Cornell University

Plan for today

  1. APIs and why economists use them
    Three ways to get data · who offers APIs · what a script gains · two studies
  2. The parts of a request and its reply
    The URL · JSON · status codes · pages · rate limits and terms of use
  3. Requesting World Bank data from R
    httr2 · an error inside a reply marked OK · JSON to a data frame · every page · saving the raw reply
  4. Hourly electricity demand from the EIA, with an API key
    Keys in ~/.Renviron · every hour of 2025 · a year in one figure · the heat-wave peak
  5. Calling a language model’s API
    Your own key · labelling 266 names · tokens and cost · when to call a model from R · keys and coding agents

In the R Console, while Part 1 starts: install.packages(c("httr2", "jsonlite")). Before Part 3: unzip 14-apis.zip and open 14-apis.Rproj.

1 · APIs and why economists use them

  • Three ways to get data from someone else’s computer
  • Who offers APIs, and which need a key or a payment
  • What a script gains over a manual download
  • Travel speeds in 180 Indian cities, from Google Maps
  • Early Twitter users by county, from Twitter’s API

Three ways to get data from someone else’s computer

Three lanes that reach the same World Bank series, access to electricity by country. Download a file: a person clicks Download, then CSV, on the series' web page, and a file lands in Downloads. Request it from an API: a program sends a web address and gets back a structured reply. Scrape a page: a program reads the page built for people and picks the numbers out of its layout. Under each lane: who does the work (you, your program, your program), what comes back (a file, a structured reply, the page's HTML) and when it fits (one download; repeated or many requests; no file and no API).

API (application programming interface): a service that answers requests sent by a program, with a reply in a fixed structure. Server: the computer that holds the data and answers requests. HTML: the text a web page is written in. JSON: a plain-text format for data.

Who offers APIs, and which need a key or a payment

Kind Source What you get Key Cost
Public statistics World Bank indicators by country and year none free
Public statistics EIA hourly grid data, energy prices free, after sign-up free
Platform Google Maps Platform travel times, places key and a billing account per request
Model provider a company that runs a language model written replies to prompts key and prepaid credits by the amount of text sent and written

World Bank: an international development lender. It publishes indicators (data series, such as access to electricity) for 217 economies (countries and territories). EIA: the US government’s statistics agency for energy (the Energy Information Administration). Platform: a company whose website or app is where people search, post or trade. Language model: the program behind a chat assistant. Key: a private string that identifies you to the service and counts your requests.

What a script gains over a manual download

  • The script records the request: its web address (URL) names the series, the places and the years.
  • The script saves each reply as it arrived, so you can rebuild the table without asking again.
  • When the source revises its numbers, you run the script again.
  • Some data have no download, such as travel times or a model’s reply. A script can request them.

A strip of three boxes joined by arrows: a script, the replies it saves to disk, and the table built from those replies.

Travel speeds in 180 Indian cities, from Google Maps

Akbar, Couture, Duranton & Storeygard (AER, 2023)

  • Question
    • How fast can people travel by car within Indian cities, and how much does traffic slow them?
  • What they did
    • They sent 57 million requests to Google Maps’ API: 2.7 million car trips in 180 cities, each at about 21 departure times.
    • One trip in three ended at a business or landmark listed on Google Places; the rest joined random points.
    • They compared these times with trips people actually took.
  • Finding
    • Average travel speed differs widely from one city to another.
    • Most of that difference comes from how fast cars move on empty roads; congestion, the slowdown from traffic, explains less.
    • Larger, denser cities are slower, only partly because of congestion.

One origin and destination pair in a city goes into Google Maps at several departure times (3:00, 9:00, 18:00 and more); for each one the reply gives the minutes and kilometres of the drive. 2.7 million trips times 21 departure times make 57 million requests.

Takeaway: an API can produce data no agency publishes, and the authors compared it with records of real trips before using it.

Early Twitter users by county, from Twitter’s API

Fujiwara, Müller & Schwarz (JEEA, 2024)

  • Question
    • Did Twitter change how people in the US voted?
  • What they did
    • Twitter (now X) use grew quickly after South by Southwest (SXSW), a festival in Austin, Texas, in March 2007.
    • They pulled the profiles of about 658,000 followers of the festival’s account through Twitter’s API.
    • Join dates and typed locations placed early users in US counties.
    • Counties with more sign-ups during the 2007 festival had more Twitter users years later.
    • Those sign-ups are unrelated to how counties voted before Twitter, so the study uses them to compare votes across counties.
  • Finding
    • Twitter lowered the vote share of the Republican Party, one of the two main US parties, in the 2016 and 2020 presidential elections, with little effect on elections to Congress (the US legislature).

The festival's Twitter account leads, through the API, to its followers' profiles with a join date and a typed location, and from there to early users by county, drawn as illustrative bars, one per county.

Takeaway: an API returns what the platform records (here, what users typed in their profiles), and the researcher decides what each field measures.

2 · The parts of a request and its reply

  • A request and its reply
  • Requesting India’s access to electricity in your browser
  • The parts of the request URL
  • Reading the reply in JSON format
  • Status codes in every reply
  • Long results arrive in pages
  • Rate limits and terms of use
  • Exercise 1

A request and its reply

Your browser, or R, sends a request, GET https://api.worldbank.org/v2/country/IND/..., India's access to electricity in 2023, to the server at api.worldbank.org. The server sends back a reply made of three parts: a status code, headers, and a body in JSON.
  • GET: the kind of request that asks for data, with everything written in the URL.
  • Reply, also called a response: what comes back.
  • Status code: a three-digit number that says if the request worked, such as 200.
  • Header: a labelled line sent with a request or reply, apart from the data, such as the body’s format (content-type) or how many requests you have left.
  • Body: the data itself.

Requesting India’s access to electricity in your browser

The request. Open this address in your browser to send a request to the World Bank’s server: https://api.worldbank.org/v2/country/IND/indicator/EG.ELC.ACCS.ZS?date=2023&format=json

The World Bank reply in Chrome with the Pretty-print box ticked: the page information (page 1, pages 1, per_page 50, total 1, sourceid 2, lastupdated 2026-10-08), then one record for India in 2023 with value 99.5.

The reply. The text on the left is the body of the server’s reply, in JSON. Here the browser indents it (Pretty-print ticked); other browsers show one long line or a tree you can expand. The status code (200, it worked) arrives too but stays out of view.

In JSON (JavaScript Object Notation), names and values come in pairs ("value": 99.5). Braces { } hold one record; square brackets [ ] hold a list.

Hands up: what number do you see, and what does it measure?

The parts of the request URL

The India request URL from the browser, on two lines. A bracket over everything before the question mark is labelled endpoint, split into the base address https://api.worldbank.org/v2, the World Bank's API version 2, and the path /country/IND/indicator/EG.ELC.ACCS.ZS, one country (India) and one series. The question mark starts the options and the ampersand joins them: date=2023 for the year and format=json for the reply's format.
  • Endpoint: the web address that answers one kind of request.
  • Query parameter: a named option after the ? that narrows what comes back. date=2000:2023 asks for a range of years.
  • Without format=json, the World Bank replies in XML, another text format.
  • Indicator code: the World Bank’s name for one series. EG.ELC.ACCS.ZS is “Access to electricity (% of population)”.
  • This API needs no key. The EIA takes its key as one more parameter (api_key=...), and model providers take it in a header.

Documentation: the World Bank’s Indicators API · the EIA’s API

Reading the reply in JSON format

On the left, the reply to the India request: a list whose first part, the page information, holds page 1, pages 1, per_page 50, total 1, sourceid 2 and lastupdated 2026-10-08, and whose second part, the records, holds one record: the indicator, the country India nested inside country, countryiso3code IND, date 2023 and value 99.5. Arrows lead to a row of a data frame on the right: country India, iso3 IND, year 2023, access 99.5. A note over the row: iso3 is the three-letter code, access is the % of population.
  • The reply to the India request has two parts: page information first, then the records.
  • The country’s name sits one level down, inside "country".
  • A CSV holds one flat table, so it cannot carry these two parts or values inside values.

Status codes in every reply

Code Meaning Example
200 it worked the India request
400 the server cannot read the request
401, 403 the key is missing or refused the EIA with no key: 403, "API_KEY_MISSING"
404 no such address a misspelled path, /v2/countryy/IND/...
429 more requests than the service allows per minute, hour or day the EIA’s shared DEMO_KEY, used up: "OVER_RATE_LIMIT"
500 and above the server failed; try again later a 5,000-row EIA page: 504 after 61 seconds

httr2, the R package used below, stops with an error at 400 and above. A 200 can still hold an error message in its body.

Long results arrive in pages

Replace IND with all in the request to get one row for each of 217 economies and 49 groups of economies, such as regions and income groups. The reply starts "page": 1, "pages": 6, "per_page": 50, "total": 266.

  • Pagination: a long result split into pages, which you request one after another with page=1, page=2, and so on.
  • total counts the rows on all pages. pages counts the requests at per_page rows each.
  • You have every row when your count equals total.
The 266 rows drawn as two bars, cut two ways: with per_page=50, the default, into six requests, five of 50 and one of 16; with per_page=100 into three requests, 100, 100 and 66. The short last pages are tinted.

Rate limits on requests

  • Rate limit: the most requests a service accepts from you per minute, hour or day. Above it, the reply is 429.
  • The World Bank code in this session waits one second between requests.
Two header lines of an EIA reply sent with DEMO_KEY, a key everyone shares
x-ratelimit-limit: 10
x-ratelimit-remaining: 2
  • In these two lines the limit is 10 requests, and 2 are left. The headers do not say over what period.
  • The EIA counts them per internet address, and a classroom shares one, so the class reads the saved replies.

Terms of use for the data an API returns

Terms of use: the rules a source sets on what you may do with its data. Read them before you build a dataset on an API.

Source Terms What they mean for you
World Bank Creative Commons Attribution licence reuse it, with credit to the source
EIA US government work, in the public domain (no copyright) use it freely; the EIA asks for a source line
Google Maps Platform its terms of service you may not store what it returns, apart from a few short-term exceptions
Twitter (now X) its developer terms free research access ended

Sources: World Bank data licences; EIA copyright and reuse; Google Maps Platform terms; X developer terms.

Exercise 1

Start from the India request. Country codes: the World Bank’s country list (search the page for your country’s name; its code is the id just before it).

# Exercise 1 (2 minutes). In your browser; no R needed.
# Start from the request on the slide:
# https://api.worldbank.org/v2/country/IND/indicator/EG.ELC.ACCS.ZS?date=2023&format=json
#
# 1. Replace IND with another country's three-letter code:
#    CHN China, BGD Bangladesh, KEN Kenya, NGA Nigeria,
#    ETH Ethiopia, BRA Brazil, or your own country's.
# 2. Change date=2023 to date=2000.
# A first request can take up to a minute. Wait, or reload the page.
#
# Two answers to compare with the room: your country's access to
# electricity (% of population) in 2023 and in 2000.
2000 2023
India 60.3 99.5
China 96.7 100.0
Bangladesh 32.0 99.5
Kenya 15.2 76.2
Nigeria 43.2 61.2
Ethiopia 12.7 55.4
Brazil 94.4 99.8

Access to electricity (% of population), World Bank (EG.ELC.ACCS.ZS)

One request with date=2000:2023 returns every year from 2000 to 2023 (24 rows).

3 · Requesting World Bank data from R

  • httr2
  • A status 200 that holds an error
  • JSON to a data frame
  • fetch() and flat()
  • Requesting all pages
  • Rebuilding the table from saved replies
  • Exercise 2

Download 14-apis.zip from the session page, unzip it, and open 14-apis.Rproj, so that R starts in the 14-apis folder.

Sending the request from R with httr2

R Console; needs internet and the httr2 package
# install.packages(c("httr2", "jsonlite"))   # once; jsonlite does not come with httr2
library(httr2)
req <- request("https://api.worldbank.org/v2/country/all/indicator/EG.ELC.ACCS.ZS")
req <- req_url_query(req, date = 2023, format = "json", per_page = 100, page = 1)
req
#> <httr2_request>
#> GET https://api.worldbank.org/v2/country/all/indicator/EG.ELC.ACCS.ZS?date=2023&format=json&per_page=100&page=1
#> Body: empty
resp <- req_perform(req)
resp_status(resp)
#> [1] 200
  • The request: access to electricity (% of population) in 2023 for every country and group (country/all), page 1 at 100 rows a page.
  • httr2: an R package that writes and sends web requests.
  • request() takes the address. req_url_query() adds the parameters after the ?.
  • Printing req shows the exact URL. Nothing has been sent yet.
  • req_perform() sends it and waits for the reply. resp_status() reads the status code.

A status 200 whose body holds an error

R Console, in the 14-apis folder; needs internet and httr2
bad <- request("https://api.worldbank.org/v2/country/all/indicator/EG.ELC.ACCS.ZZ")
bad <- req_url_query(bad, date = 2023, format = "json")
resp_bad <- req_perform(bad)
resp_status(resp_bad)
#> [1] 200
writeLines(resp_body_string(resp_bad))
#> [{"message":[{"id":"120","key":"Invalid value","value":"The provided parameter value is not valid"}]}]
# no network? writeLines(readLines("saved/wb_bad_code.json")) shows the same body
  • The indicator code ends in ZZ instead of ZS.
  • resp_body_string() returns the reply’s body as text.
  • "key":"Invalid value" names the error. This "key" is a field of the JSON, unrelated to API keys.
  • The misspelled path countryy returns status 404, and req_perform() stops R with HTTP 404 Not Found.
  • This 200 passes req_perform() with no error. The parsing steps below fail on it later with subscript out of bounds, a message that does not name the typo.
  • Read the body of every reply and count its rows.

Reading the JSON reply into R with fromJSON()

R Console, in the 14-apis folder; needs jsonlite and resp from the httr2 request (or the saved file)
library(jsonlite)
first <- fromJSON(resp_body_string(resp))
# no network? first <- fromJSON("saved/wb_2023_p1.json") gives the same
str(first[[1]])
#> List of 6
#>  $ page       : int 1
#>  $ pages      : int 3
#>  $ per_page   : int 100
#>  $ total      : int 266
#>  $ sourceid   : chr "2"
#>  $ lastupdated: chr "2026-10-08"
d <- first[[2]]
dim(d)
#> [1] 100   8
names(d)
#> [1] "indicator"       "country"         "countryiso3code" "date"           
#> [5] "value"           "unit"            "obs_status"      "decimal"
  • fromJSON() turns JSON text into R lists and data frames.
  • first is a list, R’s container for parts of different kinds: first[[1]] holds the page information (3 pages, 266 rows in all), first[[2]] this page’s 100 records.

Flattening the records into a data frame

R Console; needs d, the records of the first page
class(d$country)             # a data frame inside the data frame
#> [1] "data.frame"
wb1 <- data.frame(country = d$country$value, iso3 = d$countryiso3code,
                  year = d$date, access = d$value)
head(wb1, 3)
#>                       country iso3 year   access
#> 1 Africa Eastern and Southern  AFE 2023 50.66752
#> 2  Africa Western and Central  AFW 2023 57.06474
#> 3                  Arab World  ARB 2023 91.64543
wb1[wb1$iso3 == "IND", ]
#> [1] country iso3    year    access 
#> <0 rows> (or 0-length row.names)
  • Flatten: turn nested fields into plain columns, one value per cell.
  • d$country holds two columns, an id and the name. $value takes the name.
  • access is the share of the population with electricity, in percent.
  • India is missing: this page holds 100 of the 266 rows, and India is on the second page.

fetch() to request and save one page, flat() to flatten it

wb_functions.R (part), in the 14-apis folder; needs httr2 and jsonlite
dir.create("raw", showWarnings = FALSE)
fetch <- function(p, year, per) {
  req <- request("https://api.worldbank.org/v2/country/all/indicator/EG.ELC.ACCS.ZS")
  req <- req_url_query(req, date = year, format = "json", per_page = per, page = p)
  txt <- resp_body_string(req_perform(req))
  writeLines(txt, paste0("raw/wb_", year, "_p", p, ".json"))  # the reply as sent
  Sys.sleep(1)                                                # one request a second
  fromJSON(txt)
}
flat <- function(reply) {
  d <- reply[[2]]
  data.frame(country = d$country$value, iso3 = d$countryiso3code,
             year = d$date, access = d$value)
}
  • Run source("wb_functions.R") to define both functions. The file ships in the project.
  • fetch(p, year, per) requests page p of one year at per rows a page, writes the reply to raw/ as sent, waits one second, and returns it parsed.
  • Raw reply: the reply exactly as the server sent it, saved before any parsing.
  • flat() turns one parsed reply into the four-column table.

Requesting all pages and comparing rows with the total

R Console, in the 14-apis folder; needs internet, fetch() and flat(); writes three replies to raw/
first <- fetch(1, 2023, 100)
replies <- lapply(1:first[[1]]$pages, fetch, year = 2023, per = 100)
wb <- do.call(rbind, lapply(replies, flat))
c(rows = nrow(wb), total = first[[1]]$total)
#>  rows total
#>   266   266
wb[wb$iso3 == "IND", ]
#>     country iso3 year access
#> 139   India  IND 2023   99.5
  • Page 1’s information gives the number of pages (3) and the total (266).
  • lapply() calls fetch() once per page, passing year and per on to it.
  • do.call(rbind, ...) stacks the three pages into one table.
  • The rows received equal the total the API reports.
  • Rows 1 to 49 are groups of economies (regions, income and lending groups, World). Rows 50 to 266 are the 217 economies.

Rebuilding the table from the saved replies

R Console, in the 14-apis folder; needs jsonlite and flat() from wb_functions.R; reads saved/ (yours: raw/)
replies <- lapply(paste0("saved/wb_2023_p", 1:3, ".json"), fromJSON)
wb <- do.call(rbind, lapply(replies, flat))
nrow(wb)
#> [1] 266
replies[[1]][[1]]$lastupdated
#> [1] "2026-10-08"
  • lastupdated is the date the World Bank last revised the series. The saved files keep the version your results used.
  • A request eight days before these reported 265 rows. Its reply was not saved, so nobody can say which row was added.
  • A reply can take a minute to arrive. The saved files rebuild the table with no new request.

Exercise 2

# Exercise 2 (5 minutes). R Console, in the 14-apis folder.
# First run source("wb_functions.R"), which defines fetch() and flat().
# No network? Skip the requests and run
#   replies0 <- lapply(paste0("saved/wb_2000_p", 1:6, ".json"), fromJSON)
#   first0 <- replies0[[1]]
#
# 1. Request page 1 of the year 2000 with 50 rows a page:
#    first0 <- fetch(1, 2000, 50). How many pages? What total?
# 2. Request every page into replies0 with lapply(), then stack them:
#    wb0 <- do.call(rbind, lapply(replies0, flat))
# 3. Compare nrow(wb0) with the total.
# 4. Find India's row (iso3 is "IND").
#
# Two answers to compare with the room: the rows against the
# reported total, and India's access to electricity in 2000.
R Console, in the 14-apis folder; needs jsonlite, flat() and the six saved replies
# With the network: first0 <- fetch(1, 2000, 50), then
#   replies0 <- lapply(1:first0[[1]]$pages, fetch, year = 2000, per = 50)
replies0 <- lapply(paste0("saved/wb_2000_p", 1:6, ".json"), fromJSON)
first0 <- replies0[[1]]
c(pages = first0[[1]]$pages, total = first0[[1]]$total)
#> pages total 
#>     6   266
wb0 <- do.call(rbind, lapply(replies0, flat))
c(rows = nrow(wb0), total = first0[[1]]$total)
#>  rows total 
#>   266   266
wb0[wb0$iso3 == "IND", ]
#>     country iso3 year access
#> 139   India  IND 2000   60.3

266 rows against a total of 266. India: 60.3% of the population in 2000, the same as the browser showed in Exercise 1.

4 · Hourly electricity demand from the EIA, with an API key

  • New York’s grid in the EIA’s data
  • Getting an EIA key and storing it in ~/.Renviron
  • The EIA request in R
  • Stacking two pages into the 8,760 hours of 2025
  • A year of hourly demand in one figure
  • The heat-wave peak

Hourly demand on New York’s grid, from the EIA

One chart from the EIA's Hourly Electric Grid Monitor: NYIS demand, hour by hour, over the week before the capture, in Eastern Time, in megawatthours, between about 12,000 and 19,000.

  • The EIA publishes the hourly demand of each grid operator in the 48 contiguous US states (all but Alaska and Hawaii).
  • NYISO (New York Independent System Operator) runs New York State’s grid, Ithaca included. EIA code: NYIS.
  • Left: a week of NYISO’s demand on the EIA’s Hourly Electric Grid Monitor, its page for people. Below: the API’s first row, in R.
R Console, in the 14-apis folder; needs jsonlite and saved/eia_nyis_0.json
p1 <- fromJSON("saved/eia_nyis_0.json")
p1$response$data[1, c("period", "respondent", "type",
                      "type-name", "value", "value-units")]
#>             period respondent type type-name value   value-units
#> 1 2025-01-01T00-05       NYIS    D    Demand 15424 megawatthours

value: demand in that hour, in megawatt-hours (MWh), the average megawatts (MW, a rate of electricity use) over the hour. -05 marks New York winter time.

Getting a free EIA key

The top of the EIA's Register for an API key form, empty: fields for first name, last name, email and a category that describes you. The terms of service, the I agree box and the Register button sit further down the page.
  • At eia.gov/opendata/register.php, give your name, your email and a category, and accept the terms of service.
  • The key is free and arrives by email. In class, the replies in saved/ stand in for your own requests.
  • API key: a private string that identifies you to the EIA and counts your requests. Treat it like a password.

Storing the EIA key in ~/.Renviron

The file ~/.Renviron holds the line EIA_KEY= followed by a key. R reads the file when it starts, the script reads the key with Sys.getenv and the name EIA_KEY, and the request carries the key.

Environment variable: a named value set outside your code, which R loads when it starts.

R Console; once per computer
file.edit("~/.Renviron")         # opens the file, or creates it
# add one line, keep an empty line at the end, save:
#   EIA_KEY=paste-your-key-here
# restart R (RStudio: Session > Restart R), then:
nchar(Sys.getenv("EIA_KEY"))     # above 0: the key loaded; 0: R did not find it
  • The script holds only the name, so sharing or committing the script shares no secret.
  • ~ is your home folder (on Windows, R uses your Documents folder). A Mac hides names that start with a dot, and Windows editors may add .txt to them, so open the file from R with file.edit().

Requesting every hour of 2025 from the EIA in R

R Console, in the 14-apis folder; needs internet, httr2, jsonlite and EIA_KEY in ~/.Renviron (no key: start from saved/)
base <- request("https://api.eia.gov/v2/electricity/rto/region-data/data/")
base <- req_url_query(base,                   # backticks let R use names with brackets
  api_key = Sys.getenv("EIA_KEY"),
  frequency = "local-hourly",                 # hours in New York time
  `data[0]` = "value",                        # the demand number
  `facets[respondent][]` = "NYIS",            # New York's grid
  `facets[type][]` = "D",                     # D = demand
  start = "2025-01-01T00-05", end = "2026-01-01T00-05",  # -05: 5 hours behind UTC (4 in summer)
  `sort[0][column]` = "period",               # sort by hour, so that the
  `sort[0][direction]` = "asc")               # pages follow in order
eia_page <- function(offset) {
  req <- req_url_query(base, offset = offset, length = 5000)  # skip, then up to 5,000
  txt <- resp_body_string(req_perform(req))
  writeLines(txt, paste0("raw/eia_nyis_", offset, ".json"))
  Sys.sleep(1)
  fromJSON(txt)
}
R Console, with your own EIA key; writes raw/eia_nyis_0.json and raw/eia_nyis_5000.json
p1 <- eia_page(0)            # a page of 5,000 rows can take a minute
p2 <- eia_page(5000)
  • eia_page() is fetch() for the EIA: offset skips rows, and length caps a page at 5,000.
  • local-hourly returns New York’s clock hours. The plain "hourly" series uses UTC, the world reference clock, as many APIs do.

Stacking the two EIA pages into the 8,760 hours of 2025

R Console, in the 14-apis folder; needs jsonlite and the two saved EIA replies (or yours, in raw/)
p1 <- fromJSON("saved/eia_nyis_0.json")
p2 <- fromJSON("saved/eia_nyis_5000.json")
p1$warnings$warning
#> [1] "incomplete return"
eia <- rbind(p1$response$data, p2$response$data)
eia$demand <- as.numeric(eia$value)  # text to numbers
c(rows = nrow(eia), total = as.numeric(p1$response$total),
  hours = 365 * 24)
#>  rows total hours 
#>  8761  8761  8760
tail(eia$period, 1)                  # the last row: the end bound
#> [1] "2026-01-01T00-05"
eia <- eia[substr(eia$period, 1, 4) == "2025", ]           # keep the rows of 2025
c(rows = nrow(eia), repeated = anyDuplicated(eia$period))  # repeated 0: no hour appears twice
#>     rows repeated 
#>     8760        0
  • The end bound is included: 8,761 rows against 8,760 hours. Keep the rows dated 2025.

Drawing a year of hourly demand with plot()

R Console; needs eia from the stacking step
eia$date <- as.Date(substr(eia$period, 1, 10))       # "2025-06-24T19-04" -> 2025-06-24
eia$hour <- as.numeric(substr(eia$period, 12, 13))   # -> 19
par(mar = c(4, 6, 3, 1))                             # room for the y-axis numbers
plot(eia$date + eia$hour / 24, eia$demand, type = "l", lwd = 0.5, yaxt = "n",
     xlab = "2025, New York time", ylab = "",
     main = "Hourly electricity demand, New York's grid (NYISO), 2025. Data: EIA")
at <- seq(0, 30000, 5000)
axis(2, at = at, labels = format(at, big.mark = ","), las = 1)   # commas in the numbers
title(ylab = "Demand (megawatt-hours)", line = 4.5)
  • date + hour / 24 places each hour at its point within its day, so the line runs hour by hour through the year.

A year of hourly demand on New York’s grid

Hourly electricity demand on New York's grid through 2025. Winter evenings reach about 23,500 megawatt-hours, spring and autumn sit between about 11,000 and 21,000, and summer peaks in late June and late July reach almost 32,000. A thin line drops to zero in mid-January.

What the figure shows, and two hours recorded as zero

  • Winter evenings reach 23,521 MWh (22 January). The year’s low is 11,061 MWh (20 April, mid-afternoon).
  • Summer peaks come in late June and late July.
  • The drop to zero. Two hours of 15 January read 0; the hours before and after read 21,505 and 21,943 MWh. They look like missing reports stored as 0, so set them to NA before any mean, total or minimum, and add na.rm = TRUE to those calls.
R Console; needs eia
eia[eia$demand == 0, c("period", "demand")]
#>               period demand
#> 355 2025-01-15T18-05      0
#> 356 2025-01-15T19-05      0
eia$demand[eia$demand == 0] <- NA

The peak hour of the June 2025 heat wave

R Console; needs eia
eia[which.max(eia$demand), c("period", "demand")]   # the row with the largest demand
#>                period demand
#> 4195 2025-06-24T19-04  31857

A three-day heat wave hit New York from 23 to 25 June 2025. NYISO reported 31,857 MW on 24 June, the highest on New York’s grid since 2016. The EIA labels each hour by its end, so its row 19 is the hour from 18:00 to 19:00 (6 to 7 pm). NYSRC, the council that sets the reliability rules for New York’s grid, labels the same hour by its start, 18:00.

A timeline of 24 June 2025 with a bar from 18:00 to 19:00. Above it: EIA row 19, the hour ending at 19:00. Below it: NYSRC, hour beginning 18:00, 31,857 MW.

Source: NYSRC (New York State Reliability Council), June 2025 operations report.

5 · Calling a language model’s API

  • The three requests compared
  • Getting your own key
  • Labelling 266 names as economies or groups
  • The prompt and the request
  • Reading the reply
  • The labels against the World Bank’s own list
  • Tokens, cost and three runs
  • When to call a model from R
  • Keys and coding agents

The World Bank, EIA and model requests compared

World Bank EIA Language model
Method GET GET POST
Key none api_key in the URL x-api-key header
You send query parameters query parameters a JSON body: the model, a length cap, the prompt
You get JSON rows, pages, total JSON rows, total, warnings JSON: the text, why the model stopped, token counts
Cost free free per token
  • Prompt: the instructions and material sent in one request.
  • POST: a request that carries data in its body.
  • Token: a unit of text, often part of a word, that a model reads and writes; the provider charges per token.

The prompt leaves your computer, so send only data whose terms allow it.

Getting your own API key from the provider’s console

Five numbered boxes. In the browser, on the provider's developer console: 1, sign in or create an account at platform.claude.com; 2, billing, add credits and set a spend limit; 3, API keys, Create key; 4, copy the key, which is shown only once and starts with sk-ant-. On your computer: 5, ~/.Renviron, paste the key in, then restart R.
  • Developer console: the provider’s website for API accounts, billing and keys (here, platform.claude.com).
  • API calls are paid per token from prepaid credits. A chat subscription does not cover them.
  • The console shows the key only once, so paste it into ~/.Renviron straight away.

Today nobody needs a key. The calls run on the instructor’s computer, and their replies are in saved/.

Storing the model key in ~/.Renviron

R Console, on your own computer; once per computer
file.edit("~/.Renviron")   # the same file as EIA_KEY
# add a second line, ANTHROPIC_API_KEY=paste-your-key-here; save, restart R, then:
nchar(Sys.getenv("ANTHROPIC_API_KEY"))  # above 0: loaded; 0: not found
  • Never put a key in a script, a commit, a screenshot or a chat.
  • If one leaks, delete it in the provider’s developer console and create another.
  • nchar() shows that the key loaded without printing it on a shared screen.

The file ~/.Renviron drawn as a plain panel with numbered lines: 1, EIA_KEY=xxxxxxxx, your EIA key; 2, ANTHROPIC_API_KEY=sk-ant-xxxxxxxx, the line you add; 3, an empty last line.

Labelling the 266 names as economies or groups

R Console, in the 14-apis folder
# needs wb: source("model_call.R")
# builds it and sends nothing
s <- sort(wb$country, method = "radix")
writeLines(s[180:190])   # 11 of the 266
#> North America
#> North Macedonia
#> Northern Mariana Islands
#> Norway
#> Not classified
#> OECD members
#> Oman
#> Other small states
#> Pacific island small states
#> Pakistan
#> Palau
  • The task: label each of the 266 names in the World Bank’s 2023 table of access to electricity
    • economy: a country or a territory
    • group: a region, an income group, a lending group, or the world
  • Why this task
    • Each label needs knowledge of what the name refers to: South Africa is an economy, Sub-Saharan Africa a group.
    • The World Bank publishes the correct label for every name, so R can count how many of the model’s labels agree.

Lending group: the economies that borrow under one World Bank programme, such as IDA, which lends to the poorest countries.

Building the prompt from the 266 names

model_call.R (part), in the 14-apis folder; needs jsonlite, flat() and saved/; writes raw/model_prompt.txt
replies <- lapply(paste0("saved/wb_2023_p", 1:3, ".json"), fromJSON)
wb <- do.call(rbind, lapply(replies, flat))
# sort by name, the same way on every computer
wb <- wb[order(tolower(wb$country), method = "radix"), ]
wb$id <- 1:nrow(wb)

prompt <- paste0(
  "Below are ", nrow(wb), " names from a World Bank table. Some are single ",
  "economies (a country or a territory). Others are groups of economies ",
  "(a region, an income group, a lending group, or the world). Label each ",
  "name. Reply with CSV only: a header line id,label, then one line per ",
  "name with its number and either economy or group.\n\n",
  paste0(wb$id, ". ", wb$country, collapse = "\n"))
writeLines(prompt, "raw/model_prompt.txt")
  • The World Bank lists its 49 groups first, so a model could label by position. Sorting by name mixes the groups in with the economies.
  • method = "radix" sorts by character code and ignores the computer’s language settings, so every computer gets the same order. tolower() stops capitals from sorting first.
  • Each name gets a number; the CSV reply repeats it, so R can match each label to its name.
  • collapse = "\n" joins the names with "\n", a new line, so each name sits on its own line.

Putting the key in a header and the prompt in the body

model_call.R (part), plus req to print it; source("model_call.R") sets prompt and MODEL; sends nothing
req <- request("https://api.anthropic.com/v1/messages")
req <- req_headers(req, "x-api-key" = Sys.getenv("ANTHROPIC_API_KEY"),
                   "anthropic-version" = "2023-06-01", .redact = "x-api-key")
req <- req_body_json(req, list(model = MODEL, max_tokens = 4000,
  messages = list(list(role = "user", content = prompt))))
req
#> <httr2_request>
#> POST https://api.anthropic.com/v1/messages
#> Headers:
#> * x-api-key        : <REDACTED>
#> * anthropic-version: "2023-06-01"
#> Body: JSON data
  • Headers
    • x-api-key: the key; .redact keeps R from printing it
    • anthropic-version: the version of the request format
  • Body, in JSON
    • model: MODEL, the id on line 1 of model_call.R
    • max_tokens: a cap on the reply’s length and cost; the saved replies used about 1,500
    • messages: the prompt, as one message from the user

req_body_json() makes the request a POST. Nothing is sent until req_perform().

Sending the model request from R (live)

model_send.R (part), instructor's computer; needs internet, req and a key
resp <- req_perform(req)              # about 11 seconds
writeLines(resp_body_string(resp), "raw/model_live.json")
reply <- fromJSON("raw/model_live.json")
print(reply$stop_reason)              # "end_turn": the model finished
labels <- read.csv(text = reply$content$text[reply$content$type == "text"])
print(table(labels$label))            # each saved run: economy 217, group 49
  • Each call costs money. The reply is saved to raw/model_live.json.
  • The three calls sent before class took 11.0 to 11.2 seconds each (saved/model_runs.csv).
  • If it fails, the next slide shows a reply saved before class.

Reading the text and the stop reason in the model’s reply

Reply to the labelling prompt, run on the instructor’s computer before class (saved/model_reply_1.json).

The file, shortened to the fields used here, with the CSV text cut after two labels.

{
  "content": [
    {
      "type": "thinking",
      "thinking": ""
    },
    {
      "type": "text",
      "text": "id,label\n1,economy\n2,group\n..."
    }
  ],
  "stop_reason": "end_turn",
  "usage": {
    "input_tokens": 2597,
    "output_tokens": 1488,
    "output_tokens_details": {
      "thinking_tokens": 202
    }
  }
}
R Console, in the 14-apis folder; needs jsonlite; no key
reply <- fromJSON("saved/model_reply_1.json")
reply$content$type
#> [1] "thinking" "text"
reply$stop_reason
#> [1] "end_turn"
txt <- reply$content$text[reply$content$type == "text"]
labels <- read.csv(text = txt)   # CSV from a string
str(labels)                      # 266 rows, one per name
#> 'data.frame':    266 obs. of  2 variables:
#>  $ id   : int  1 2 3 4 5 6 7 8 9 10 ...
#>  $ label: chr  "economy" "group" "group" "economy" ...
  • The content can begin with a thinking block: the reasoning the model writes before its answer. Its text is left out of the reply by default.
  • Stop reason: why the reply ended. "end_turn": the model finished. "max_tokens": your cap cut it off mid-list. Read it before the text.

The World Bank’s own list of economies and groups

R Console, in the 14-apis folder; needs internet and httr2; no key; writes raw/wb_countries.json
req_cl <- request("https://api.worldbank.org/v2/country")
req_cl <- req_url_query(req_cl, format = "json", per_page = 400)   # every entry, one page
writeLines(resp_body_string(req_perform(req_cl)), "raw/wb_countries.json")

The copy saved before class ships in saved/:

R Console, in the 14-apis folder; needs jsonlite and saved/wb_countries.json (or yours, in raw/)
cl <- fromJSON("saved/wb_countries.json")[[2]]
nrow(cl)
#> [1] 296
table(cl$region$value == "Aggregates")   # TRUE: a group of economies
#> 
#> FALSE  TRUE 
#>   217    79
  • Each entry has a name and a region. The World Bank gives every group the region "Aggregates".
  • The list has 296 entries: the same 217 economies as the table, and 79 groups.
  • The table uses 49 of the 79 groups.
  • Reference list: labels you already trust, used to measure the model’s.

The model’s labels compared with the World Bank’s list

R Console; needs wb from model_call.R, labels from the reply, and cl
miss <- is.na(match(wb$country, cl$name))   # TRUE: no exact match in the list
wb$country[miss]
#> [1] "Latin America & Caribbean" "Sub-Saharan Africa"
pos <- match(trimws(wb$country), trimws(cl$name))   # each name's row in the list
wb$reference <- ifelse(cl$region$value[pos] == "Aggregates", "group", "economy")
wb$model <- labels$label[match(wb$id, labels$id)]   # each label on its name, by number
table(model = wb$model, list = wb$reference)
#>          list
#> model     economy group
#>   economy     217     0
#>   group         0    49
sum(wb$model == wb$reference)
#> [1] 266
  • The list writes those two names with a space at the end ("Sub-Saharan Africa "). trimws() removes spaces at the start and end of a text, so every name finds its row.
  • The model agrees with the World Bank on all 266 names.
  • The model has likely seen this list: these names and groups are on many public pages.
  • On your own texts, measure agreement on a hand-labelled sample before trusting the rest.

Tokens, cost and three runs of the same request

R Console, in the 14-apis folder; needs jsonlite, the three saved replies and wb with its reference column
runs <- lapply(paste0("saved/model_reply_", 1:3, ".json"), fromJSON)  # three runs of the labelling request
run_row <- function(r) {
  lab <- read.csv(text = r$content$text[r$content$type == "text"])
  data.frame(input = r$usage$input_tokens, output = r$usage$output_tokens,
             reasoning = r$usage$output_tokens_details$thinking_tokens,
             agree = sum(lab$label[match(wb$id, lab$id)] == wb$reference))
}
do.call(rbind, lapply(runs, run_row))   # tokens; agree: names whose label matches the list
#>   input output reasoning agree
#> 1  2597   1488       202   266
#> 2  2597   1489       203   266
#> 3  2597   1492       206   266
  • Cost in dollars = (input tokens × input price + output tokens × output price) / 1,000,000, with prices per million tokens on the provider’s pricing page. Each name adds about 10 tokens in and 6 out.
  • All three runs gave the same 266 labels, each matching the list. Nothing guarantees the same labels on the next run, so each reply is saved.
  • Temperature, a setting for how much replies vary: the same request sent with temperature = 0 got status 400, because this model no longer accepts it.

When to call a model from R, and when to have it write R

Two lanes. Call a model from R, when each item needs reading: your R script sends one instruction and the list of items to the model through its API, the model labels each item, and the labels come back as data, saved and compared with a reference. Have a model write R, when a rule gives the answer: a chat assistant or a coding agent writes R code, which you read and keep, and R runs it to give the exact answer, such as a maximum, a regression or a figure.

  • Call a model from R, through its API, in your script
    • examples: job titles to occupation codes, open survey answers to categories
    • the same request reruns on thousands of items
    • today: the 266 names, scored by the World Bank’s list
  • Have a model write R, in a chat assistant or a coding agent (a program that writes and runs code on your computer)
    • R computes the answer exactly and for free
    • a task of this kind today: the peak hour, one line of R (which.max()) on 8,760 hours

Rule of thumb: if a formula, a rule or a list gives the answer, compute it in R. If each item needs reading, send the items to the model and measure its labels on a hand-labelled sample.

Keeping API keys out of the repo and away from the agent

When a coding agent writes and runs your R code:

  • The key stays in ~/.Renviron, outside the project. The code reads Sys.getenv("EIA_KEY"); the agent never needs the key itself.
  • Never paste a key into the chat. What the agent reads, files and printed output included, goes to the model’s provider with the conversation.
  • Keep ANTHROPIC_API_KEY out of your shell’s startup file. An agent started from that shell can use it and bill your API credits instead of your subscription.
  • Before each commit, read what you are about to commit (git diff --cached).
  • If a key leaks, delete it in the provider’s console and create a new one. Removing it from the repo does not undo the leak.
CLAUDE.md: the agent's instructions, in the project
API keys are in ~/.Renviron.
Read them with Sys.getenv("NAME").
Never print a key, write it to a
file or put it in printed output.
.claude/settings.json: the agent may not open the file
{
  "permissions": {
    "deny": ["Read(~/.Renviron)"]
  }
}

The instructions are a request. The setting is enforced on the agent’s own reads, cat included, but not on the code it runs: R still reads the file when it starts, which is how your script gets the key.

Practice

Ten exercises on the session page, with solutions behind a click, all in the 14-apis project. Tasks 6 and 7 need your own EIA key. Only the last step of task 10 needs your own model key.

  • 1–3 World Bank requests: agricultural land in 2020; China and India, 2000–2023; 2023 at 20 rows a page
  • 4–5 Errors and packages: misspelled codes that still return status 200; the same table from WDI, an R package for World Bank data
  • 6–8 The EIA: one day of New York demand; every hour of 2025 for Texas or California; the ten days with New York’s highest peak demand
  • 9–10 Model replies: tokens per name and cost of the saved replies; 20 names labelled by hand, compared with the model’s labels