The Off the Upright API
What the API is
The Off the Upright API gives approved key holders doink data as JSON the moment it is added to the Off the Upright catalog: doinks, kickers and franchises, with the calculated fields the site uses, such as stakes tags and career records. A change feed lists what was added, updated or removed, so removals reach you too. A keyed CSV export is also available. Bulk data and the R package will be published on GitHub Releases (link coming).
Getting a key
Request a key at /developers/request. You'll give a project name and a contact email, and you can say what you plan to build. I read every request and approve keys by hand. I send approved keys to the contact email you gave. A key is shown once, when it's issued, and can't be retrieved afterward, so save it when it arrives. The request form asks you to accept the terms on this page.
Authentication
Send your key in an Authorization header on every request, exactly like this:
Authorization: Bearer otu_live_<your 40 character key>
curl -H "Authorization: Bearer $OTU_KEY" "https://offtheupright.com/api/v1/doinks/2025"
All requests are GET (or HEAD) over https://. Plain http:// requests put your key on the wire in the clear, so don't use them.
Never put the key in the URL. A request that carries api_key=, key=, token= or access_token= in the query string is refused with a 400 and the key is not checked. URLs are written to server logs, so if a key was ever put in one, treat it as exposed: write to contact@offtheupright.com and ask for a replacement.
You may call the API from a browser page, and cross-origin requests are allowed. A key in a public page is visible to everyone who views the page, so see the terms below.
Endpoints
Every path is under https://offtheupright.com/api/v1/. A season is the NFL season as it is stored, not the calendar year, from 1974 to the current calendar year plus one. Decades are written 1970s, 1980s and so on, up to the current decade. Every parameter and value is checked, and an unknown one returns a 400.
| Path | Filters | Returns |
|---|---|---|
doinks | season, week, franchise (slug), kicker (slug or id), play_type (FG or XP), outcome (good or no-good), official (1) | Every doink, as doink rows. Filters combine with AND. |
doinks/{season} | none | That season's doink rows and a summary block. |
doinks/decade/{decade} | none | That decade's doink rows, with seasons and leaders. |
kickers | none | Every kicker, with career records for all doinks and for official games only. |
kickers/{season} | none | Kickers with a doink that season: doink count, games played, rate and team. |
franchises | none | Every franchise with a doink, with all-time records for all doinks and for official games only. |
franchises/{season} | none | One row per franchise for that season. |
franchises/decade/{decade} | none | One row per franchise, summed over the decade's seasons. |
changes | since (required) | Doinks added, updated and removed since a time. See below. |
export | none | The whole dataset as CSV. |
Every JSON response uses one envelope. data holds the results, count is the number of items, and next is always null for now. It is reserved for paging if a response ever outgrows one body. Season and decade endpoints add summary, seasons or leaders next to data.
Example responses are below. The values are placeholders, not records, and several are shortened.
/api/v1/doinks
{
"api_version": "v1",
"generated_at": "2025-11-03T14:00:00Z",
"count": 1,
"next": null,
"data": [
{
"doink_id": 1234,
"kicker": {
"kicker_id": 57,
"name": "Example Kicker",
"slug": "example-kicker",
"gsis_id": null,
"is_position_kicker": true
},
"kicking_team": "PHI",
"kicking_franchise": "philadelphia-eagles",
"play_type": "FG",
"distance": 48,
"quarter": "4",
"game_time": "0:03",
"outcome": "No Good",
"stakes": "losing",
"margin": -2,
"is_official": true,
"career_doink_no": 7,
"game": {
"season": 2025,
"week": "9",
"is_postseason": false,
"game_date": "2025-11-02",
"away": "PHI",
"home": "DAL",
"away_score": 21,
"home_score": 23,
"stadium": "Example Stadium",
"nflverse_game_id": null,
"url": "https://offtheupright.com/game/2025-w9-phi-dal"
},
"created_at": "2025-11-02T23:41:07Z",
"updated_at": "2025-11-03T01:12:50Z"
}
]
}
/api/v1/doinks/2025
{
"api_version": "v1",
"generated_at": "2025-11-03T14:00:00Z",
"count": 1,
"next": null,
"summary": {
"doinks": 31,
"made": 12,
"no_good": 19,
"made_pct": 38.7,
"fg": 26,
"xp": 5,
"official": {
"doinks": 30,
"made": 12,
"no_good": 18,
"fg": 25,
"xp": 5
}
},
"data": [
{
"doink_id": 1234,
"kicker": {
"kicker_id": 57,
"name": "Example Kicker",
"slug": "example-kicker",
"gsis_id": null,
"is_position_kicker": true
},
"kicking_team": "PHI",
"kicking_franchise": "philadelphia-eagles",
"play_type": "FG",
"distance": 48,
"quarter": "4",
"game_time": "0:03",
"outcome": "No Good",
"stakes": "losing",
"margin": -2,
"is_official": true,
"career_doink_no": 7,
"game": {
"season": 2025,
"week": "9",
"is_postseason": false,
"game_date": "2025-11-02",
"away": "PHI",
"home": "DAL",
"away_score": 21,
"home_score": 23,
"stadium": "Example Stadium",
"nflverse_game_id": null,
"url": "https://offtheupright.com/game/2025-w9-phi-dal"
},
"created_at": "2025-11-02T23:41:07Z",
"updated_at": "2025-11-03T01:12:50Z"
}
]
}
/api/v1/kickers
{
"api_version": "v1",
"generated_at": "2025-11-03T14:00:00Z",
"count": 1,
"next": null,
"data": [
{
"kicker_id": 57,
"name": "Example Kicker",
"slug": "example-kicker",
"url": "https://offtheupright.com/kicker/example-kicker",
"career": {
"all": {
"total": 12,
"made": 5,
"nogood": 7,
"made_pct": 41.7,
"fg_count": 11,
"fg_made": 5,
"xp_count": 1,
"xp_made": 0,
"avg_fg_dist": 46.2,
"longest_dist": 53,
"longest_outcome": "No Good",
"wins": 6,
"losses": 6,
"ties": 0,
"total_games": 12,
"win_pct": 50,
"seasons": [
2023,
2024,
2025
]
},
"official": {
"total": 12,
"made": 5,
"nogood": 7,
"made_pct": 41.7,
"fg_count": 11,
"fg_made": 5,
"xp_count": 1,
"xp_made": 0,
"avg_fg_dist": 46.2,
"longest_dist": 53,
"longest_outcome": "No Good",
"wins": 6,
"losses": 6,
"ties": 0,
"total_games": 12,
"win_pct": 50,
"seasons": [
2023,
2024,
2025
]
}
},
"career_doink_rate": 0.08
}
]
}
/api/v1/franchises
{
"api_version": "v1",
"generated_at": "2025-11-03T14:00:00Z",
"count": 1,
"next": null,
"data": [
{
"franchise_id": 21,
"slug": "philadelphia-eagles",
"display_name": "Philadelphia Eagles",
"url": "https://offtheupright.com/franchise/philadelphia-eagles",
"abbreviations": [
{
"abbreviation": "PHI",
"first_year": 1933,
"last_year": null
}
],
"all_time": {
"all": {
"doinks_for_count": 40,
"doinks_against_count": 38,
"total_doinks": 78
},
"official": {
"doinks_for_count": 39,
"doinks_against_count": 38,
"total_doinks": 77
}
}
}
]
}
/api/v1/changes?since=2025-11-02T00:00:00Z
{
"api_version": "v1",
"generated_at": "2025-11-03T14:00:00Z",
"next": null,
"data": {
"since": "2025-11-02T00:00:00Z",
"until": "2025-11-03T14:00:00Z",
"added": [
{
"doink_id": 1234,
"kicker": {
"kicker_id": 57,
"name": "Example Kicker",
"slug": "example-kicker",
"gsis_id": null,
"is_position_kicker": true
},
"kicking_team": "PHI",
"kicking_franchise": "philadelphia-eagles",
"play_type": "FG",
"distance": 48,
"quarter": "4",
"game_time": "0:03",
"outcome": "No Good",
"stakes": "losing",
"margin": -2,
"is_official": true,
"career_doink_no": 7,
"game": {
"season": 2025,
"week": "9",
"is_postseason": false,
"game_date": "2025-11-02",
"away": "PHI",
"home": "DAL",
"away_score": 21,
"home_score": 23,
"stadium": "Example Stadium",
"nflverse_game_id": null,
"url": "https://offtheupright.com/game/2025-w9-phi-dal"
},
"created_at": "2025-11-02T23:41:07Z",
"updated_at": "2025-11-03T01:12:50Z"
}
],
"updated": [],
"removed": [
{
"doink_id": 999,
"removed_at": "2025-11-03T09:30:00Z",
"reason": "Duplicate entry",
"snapshot": {
"season": 2025,
"week": "9",
"game_date": "2025-11-02",
"away": "PHI",
"home": "DAL",
"kicker_name": "Example Kicker",
"kicking_team": "PHI",
"play_type": "FG",
"distance": 48,
"quarter": "4",
"game_time": "0:03",
"outcome": "No Good"
}
}
]
}
}
kickers/{season}, franchises/{season} and the decade endpoints follow the same envelope with the fields described in their rows. export returns text/csv with one header row and a download filename of the form offtheupright-doinks-YYYY-MM-DD.csv (the date is UTC).
The doink row
Every endpoint that returns doinks uses the same row.
distance,quarter,game_time,outcome,play_typeandweekare the stored values, unchanged. Team abbreviations are Off the Upright's own. Matching them to another source's codes is up to you.stakesiswinning,losing,high-impactornull. It uses the same rules as the game pages. A made kick by the team that went on to win, in overtime or at the end of the fourth quarter (or late in a close game, for field goals), iswinning. A missed kick by the team that went on to lose, at the end of the game, islosing.high-impactis a kick in a close game that could plausibly have changed the result. A game has at most onewinningand onelosingdoink, and earlier ones are demoted. It isnullwhen a score is missing.marginis the final score from the kicking team's side. Positive means the kicking team won. It isnullwhen a score is missing.is_officialisfalsefor preseason and Hall of Fame games, andtrueotherwise.official=1ondoinksdrops those rows.game.is_postseasonistruefor Wild Card, Divisional, Conference and Super Bowl games.career_doink_nois the kicker's career position for this doink, where 1 is the earliest.game.nflverse_game_idis reserved and isnullfor now.game.urlis the page for the game. Use it when you credit the source.created_atandupdated_atare UTC, formattedYYYY-MM-DDTHH:MM:SSZ.
Editorial notes and video links are not included.
Conditional requests and caching
Responses are live: a new doink appears the moment it is added. Every cacheable response carries an ETag and a Last-Modified header and Cache-Control: private, max-age=60. Send the ETag back as If-None-Match (or the date as If-Modified-Since) and an unchanged response comes back as 304 Not Modified with no body. The ETag changes whenever the catalog does, so a 304 is the cheapest way to poll. The changes endpoint is never cached. Send Accept-Encoding: gzip to get compressed bodies.
Rate limits
Each key has a bucket of 60 requests that refills at one request per second, which is 3,600 requests an hour sustained. Every keyed response includes X-RateLimit-Limit (the bucket size) and X-RateLimit-Remaining (what is left after this request). When the bucket is empty the response is 429 with a Retry-After header in seconds. Wait that long before the next request. Rate limits count requests, including ones answered with a 304.
Changes feed
changes?since= takes a UTC time, either 2025-11-02T00:00:00Z or just a date, 2025-11-02. It returns three lists for the window from since up to the until time in the response:
added: doinks created at or aftersince.updated: doinks that existed beforesinceand were edited at or after it.removed: doinks deleted at or aftersince, each with the reason and a snapshot of what it was. A doink added and then removed inside the window appears only inremoved.
Keep the until value and use it as your next since. The window can be at most 400 days. An older since is a 400 error. Use doinks or export for a full copy.
Terms of use (version 1)
By requesting a key, you agree to these terms.
- Credit the source. When you publicly display data from the API, name Off the Upright and link to offtheupright.com or the game page. Every doink row includes the game's URL.
- One key per project. Use a separate key for each project, and don't share yours. You may put a key in a public web page or app, but then it is public: if it leaks or is misused, it may be revoked.
- Stay within the rate limits. They are published on this page. A key that keeps exceeding them may be suspended.
- Keys can be revoked. Off the Upright may revoke a key at any time and will send the reason to your contact email. Revocation is not a ban: you can request a new key the same way, though approval remains at Off the Upright's discretion.
- The data is provided as is, as it appears in the Off the Upright catalog. You may cache and store it to run your project, but don't republish it as a standalone dataset or offer it through your own API without asking first. The API is read-only, so report errors through the site's correction forms.
- Your email is for your key only. Off the Upright uses it to issue, warn about or revoke your key, and will delete it when you ask or when your key is deleted.
Errors
Errors are JSON, with the same leading keys as a success and then error:
{"api_version":"v1","error":{"code":"bad_request","message":"Unknown parameter: seasons"}}
| Status | error.code | When |
|---|---|---|
| 400 | bad_request | Unknown parameter, malformed value, a missing or unreadable since, a season or decade out of range, or a key sent in the URL. |
| 401 | unauthorized | No Authorization header, not a Bearer key, or a key that is unknown, revoked or expired. The message never says which. |
| 404 | not_found | A path under /api/v1/ that no endpoint serves. |
| 405 | method_not_allowed | Any method except GET or HEAD. The Allow header lists them. |
| 429 | rate_limited | Your bucket is empty. Wait for Retry-After seconds. |
| 500 | server_error | Something failed on our side. The message is fixed text. |