The API
Add notes, files and places, and keep Upcoming dates, from a terminal, a script or an app, with the requests, the errors, the limits and recipes to copy.
The idea
Anything that can send a web request can add a note: a terminal, a script, a home automation, an app. Send a token you made (see API tokens) and some words, and they become a note on today’s release, in your own voice, with photos, recordings and PDFs if you like, and where you were. A token can keep your Upcoming dates too, and read the reminders each morning brings, so an app can remind you on your phone.
Every request goes to https://notes.yourversionnumber.com/v1, and /v1/openapi.json there describes the whole API for tools that read OpenAPI, with no token needed. For now only Jamie can make tokens; they open to every account after that.
Your first note
Keep your token in an environment variable, so it stays out of your shell’s history, then:
curl https://notes.yourversionnumber.com/v1/notes \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: text/plain" \
-H "Accept: text/plain" \
--data-binary "Fixed the gate latch #house"
It answers with one line: Filed in 5.2.113.
Every request
- Send the token as
Authorization: Bearer rn_..., never in the address. - Bodies are JSON, at most 128 KB, and a note is up to 20,000 characters.
- With
Accept: text/plain, every answer is one short line instead, for terminals. - A day is written
YYYY-MM-DD, in the time zone your account keeps. A time has its offset, like2026-10-29T07:45:00-05:00. - Every answer says today’s version in
Release-Notes-Versionand the API’s own inAPI-Version.
The requests
GET /v1/me: today’s date and version, the versions for the next 14 days, your streak, your time zone, when your email goes, and this token’s name, what it may do and when it expires. Never your birthday. Any token may ask.POST /v1/notes: add a note. Send{"text": "…"}, or the words alone astext/plain. Hashtags in the words are its tags, and links get their names, as on the web.dateputs it on an earlier day, back to your birthday.written_atis the moment the words were typed: a note sent late files to the day it was typed. With neither, it’s today’s.uploadsgives it files (see Photos, recordings and PDFs), and then it needs no words.placesays where it was (see Places). The answer has the note’sid,date,versionandrevision, and how manyfilesit has.GET /v1/notes/{date}/{id}: one note this token added, with its text,revisionandwritten_at.PUT /v1/notes/{date}/{id}: replace its text. Send{"text": "…", "revision": "…"}with the revision you read; a note changed since, on the web say, is refused with the current one.POST /v1/notes/{date}/{id}/append: add a paragraph to it, with{"text": "…"}.POST /v1/notes/{date}/{id}/media: add files to it, with{"uploads": […]}.POST /v1/uploads: a form to upload one file to, before a note takes it.DELETE /v1/notes/{date}/{id}: delete it.GET /v1/tags: your tag row, the pinned tags and then up to 20 recent ones, the tags used most in the last 30 days. Any token may ask.PUT /v1/tags/pinned: replace your pinned tags, with{"pinned": ["house", "work"]}, up to 12 tags. They’re the same ones the web shows under the note box.GET /v1/places: the cities by a name, like?q=Zürich, for a note’s place.POST /v1/places/here: the city, neighbourhood and businesses nearby for the spot a device is at, for a note’s place.GET /v1/upcoming: your Upcoming dates, soonest first, each with itsid, itsrevision, itsnextdate and the line the email says about it. A trip or event that’s over is left out and only counted, aspast_left_out.POST /v1/upcoming/batch: add, change and remove dates together, up to 50 dates a request, as{"add": […], "change": […], "remove": […]}. It saves all or nothing: if one date isn’t right, none is saved, andfieldnames the one, likeadd[1]. With"dry_run": trueit checks everything and finds each trip’s city, and saves nothing. The answer lists what wasadded, in the order you sent it, then what waschangedandremoved.GET /v1/upcoming/reminders: the reminders each morning’s email carries, day by day, for the next 14 days, or up to 31 days ahead withdays. Each day hassend_at, the moment that morning’s email is due, and each reminder the date’sidasupcoming, the day it falls on asdate, and itswhenandline, as the email says them. A date with its email reminders turned off isn’t there; a paused email doesn’t change the list.DELETE /v1/upcoming/{id}: remove one date, with the revision you read as?revision=.GET /v1/activity: your activity log, newest first, without any words: no note text, no email’s subject or sender, no setting’s value.aftergives only rows newer than one you’ve seen, andbefore, with thenextfrom the answer, the page older.kindskeeps some kinds, likenote,mail, andlimitsets how many rows a page holds.DELETE /v1/token: this token revokes itself. Any token may ask.
Adding, changing and deleting notes, pinning tags and finding places need a token that may add notes, and only ever touch the notes that same token added. Files need a token that may add files too, which comes only with adding notes. Upcoming needs a token that may read and change your Upcoming dates; it reaches all of them, not only the ones it added, and no other token reaches them. The activity log needs a token that may read it.
Errors
Every error is the same shape, a code that never changes meaning and a sentence for a person that says what to do next:
{ "error": { "code": "token_expired", "message": "This token expired. Make a new one under Settings, API tokens." } }
Act on the code, and show the sentence. Here is each one, with its HTTP status:
invalid_request(400): something in the request isn’t right, andfieldnames it.text_required(400): a note needs words.date_out_of_range(400):dateis before your birthday or after today.token_missing(401): no token was sent.token_unknown(401): that isn’t a token Release Notes made.token_revoked(401): the token was revoked.token_expired(401): the token expired.scope_required(403): this token may not do that.not_yours(403): this token didn’t add that note.origin_refused(403): the API isn’t for web pages in a browser.not_found(404): there is no such request, or no such note or date, or no city for the spot sent to Here.method_not_allowed(405): that request doesn’t take that method.revision_mismatch(409): the note or date changed since you read it. For a note,revisionis its current one; for a date, readGET /v1/upcomingagain.account_closing(409): the account is being deleted.busy(409): try again shortly, with the sameIdempotency-Key.too_large(413): the request is over 128 KB, or a file is over its limit, 50 MB a file.idempotency_mismatch(422): thatIdempotency-Keywent with a different request.rate_limited(429): a limit for the day is reached, andRetry-Aftersays how many seconds to wait.unavailable(503): try again shortly, with the sameIdempotency-Key.
Sending a note once
On a bad connection you can’t always tell whether a note arrived. Send an Idempotency-Key header with each write, a string you make for that one note, like a random UUID, and send the same key if you try again. Release Notes answers a repeat with what it answered the first time, marked Idempotent-Replayed: true, and adds nothing. A key holds for 7 days.
Limits
Each day, from midnight UTC, an account may add 100 notes, make 200 other changes (appends, edits, deletes, files added to a note and pinned tags), save Upcoming 50 times and name 100 spots with Here, across all its tokens, and each token may read 2,000 times. An Upcoming dry run and a city search count as reads. Uploads count with the web’s: 200 files and 2 GB a day. Past a limit the answer is rate_limited, with Retry-After. Every write says where it stands in RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset.
Photos, recordings and PDFs
A file goes in three steps:
- Ask for a form with
POST /v1/uploads, giving the file’stype, likeimage/jpeg, its exactsizein bytes, and itsnameif it has one. The answer has the upload’s id asupload, thetypeit was signed for, and aurland itsfields. - Post the file to that
urlwithin 5 minutes, asmultipart/form-data: every one of thefields, then the file itself, last, asfile. - Name it in a note’s
uploads, as{"upload": "…", "type": "image/jpeg", "name": "lake.jpg"}: onPOST /v1/notesfor a new note, or onPOST /v1/notes/{date}/{id}/mediato add it to one this token added.
form=$(curl -fsS https://notes.yourversionnumber.com/v1/uploads \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: application/json" \
--data "$(jq -n --argjson size "$(wc -c < lake.jpg)" '{name: "lake.jpg", type: "image/jpeg", size: $size}')")
curl -fsS "$(jq -r .url <<<"$form")" \
$(jq -r '.fields | to_entries[] | "--form-string \(.key)=\(.value)"' <<<"$form") \
-F file=@lake.jpg
curl -fsS https://notes.yourversionnumber.com/v1/notes \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/plain" \
-H "Idempotency-Key: $(uuidgen)" \
--data "$(jq '{text: "The lake at dawn #lake", uploads: [{upload, type, name: "lake.jpg"}]}' <<<"$form")"
Release Notes checks each file against what arrived, then keeps it with the note, as it keeps one you add on the web: a photo gets its thumbnail and, on a note with no place of its own, the place it was taken, and photos and recordings get the descriptions and transcripts you’ve turned on. A note can have up to 20 files, at 50 MB a file. A note takes each upload once, and an upload no note takes is gone after a day. A form has no Idempotency-Key: if an upload fails, ask for a new one.
Send photos as JPEG, PNG or WebP where you can. A HEIC photo is kept, with its time and place, but gets no thumbnail, so it never shows in the email.
Places
A note’s place says where it was. Send one of two things, as it came:
- A city from
GET /v1/places?q=Zürich, which answers the cities by that name, each with itsregion,country, time zone and spot. Pick the right one: two cities can share a name. - The spot a device is at, from
POST /v1/places/here. Sendlatandlonin degrees, and the device’saccuracy_min metres if it has one. The answer’splacehas the spot’s city and its neighbourhood asdistrict, andnearbylists up to 6 businesses within the accuracy. If the person picks one, send the place withvenueset to its name; nothing is picked for them. The spot is kept to six decimal places, and nothing is kept until a note takes it.
On the web, a note with a spot links to your map. A note with no place shows the day’s city, or your home’s.
Upcoming dates
Each date has a kind (birthday, anniversary, trip or event), a title on one line, and a date: month and day, MM-DD, for a birthday or anniversary, and the full YYYY-MM-DD for an event or a trip’s first day. A birthday or anniversary may have its first year, so the line can say which one it is. A trip has an end_date and a place, its destination, like “Zürich” or “Paris, Texas”. tag groups dates, reminders are the days before to remind, and "email": false keeps a date’s reminders out of the morning email, and so out of GET /v1/upcoming/reminders. Upcoming says how each works, and /v1/openapi.json has every field.
curl https://notes.yourversionnumber.com/v1/upcoming/batch \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data '{"add": [{"kind": "birthday", "title": "Mom", "date": "11-04", "year": 1955}]}'
To change a date, send its id and revision in change, with every field again as it should be. To remove one, send its id and revision in remove.
From the terminal
A shell function, in your .zshrc or .bashrc:
rn() {
curl --fail-with-body -sS https://notes.yourversionnumber.com/v1/notes \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: text/plain" \
-H "Accept: text/plain" \
--data-binary "$*"
}
Then rn "Fixed the gate latch #house". Quote a note with a hashtag in it, since some shells take a # as the start of a comment.
An Apple Shortcut
Build it once in the Shortcuts app:
- Add Ask for Input, with the prompt “What happened?”.
- Add Get Contents of URL, with
https://notes.yourversionnumber.com/v1/notes. Under it, set the method to POST; add the headersAuthorization, withBearerand your token, andAccept, withtext/plain; and set the request body to JSON, with one text field namedtextset to Provided Input. - Add Show Notification, with Contents of URL, to see the version it was filed in.
On an iPhone with an Action Button, choose the shortcut under Settings, then Action Button, for a note at one press. The token lives inside the shortcut, so never share the shortcut itself.
A note for each commit
A git post-commit hook, saved as .git/hooks/post-commit and made executable with chmod +x:
#!/bin/sh
# One note for each commit, sent in the background.
repo=$(basename "$(git rev-parse --show-toplevel)")
curl -fsS https://notes.yourversionnumber.com/v1/notes \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: text/plain" \
-H "Idempotency-Key: $(git rev-parse HEAD)" \
--data-binary "Committed to $repo: $(git log -1 --pretty=%s) #code" >/dev/null 2>&1 &
The commit’s own id is the key, so a commit is never noted twice.
On a schedule
A job run by cron or launchd doesn’t see your shell’s variables, so keep the token in a file only you can read (chmod 600) and read it there. A script that notes a backup when it finishes:
#!/bin/sh
RN_TOKEN=$(cat "$HOME/.config/release-notes/token")
if restic backup "$HOME/Documents"; then
curl -fsS https://notes.yourversionnumber.com/v1/notes \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: text/plain" \
--data-binary "The nightly backup ran #backup" >/dev/null
fi
In a crontab, 30 23 * * * $HOME/bin/backup-and-note.sh runs it every night at half past eleven. On a Mac, a launchd agent with a StartCalendarInterval runs it the same way.
Python
With nothing to install:
import json, os, urllib.request, uuid
def note(text, key=None, **fields):
"""Add a note. Pass the same key again to retry without a second note."""
request = urllib.request.Request(
"https://notes.yourversionnumber.com/v1/notes",
data=json.dumps({"text": text, **fields}).encode(),
headers={
"Authorization": "Bearer " + os.environ["RN_TOKEN"],
"Content-Type": "application/json",
"Idempotency-Key": key or str(uuid.uuid4()),
"User-Agent": "my-script/1.0",
},
)
with urllib.request.urlopen(request, timeout=30) as answer:
return json.load(answer)["note"]
print(note("Finished the first draft #writing")["version"])
A GitHub Actions step
Keep the token as a repository secret named RN_TOKEN, then add a step:
- name: Note the release
env:
RN_TOKEN: ${{ secrets.RN_TOKEN }}
run: |
curl -fsS https://notes.yourversionnumber.com/v1/notes \
-H "Authorization: Bearer $RN_TOKEN" \
-H "Content-Type: text/plain" \
-H "Idempotency-Key: $GITHUB_RUN_ID" \
--data-binary "Shipped $GITHUB_REPOSITORY #shipped"
The run’s id is the key, so running the job again doesn’t add the note twice.
Home Assistant
A REST command in configuration.yaml, with the token in secrets.yaml as release_notes_auth: "Bearer rn_...":
rest_command:
release_note:
url: https://notes.yourversionnumber.com/v1/notes
method: post
headers:
Authorization: !secret release_notes_auth
Accept: text/plain
content_type: application/json
payload: '{"text": {{ text | to_json }}}'
Then any automation can add a note:
action: rest_command.release_note
data:
text: 'The garage door was left open for an hour #house'
Building an app
Building a client says how an app that adds notes should behave, offline and on, so every client acts alike.