GET /api/v1/meprofile:read
Who the learner is: login, avatar and when they joined (milliseconds since the epoch).
{ "login": "octocat", "avatar": "https://avatars.githubusercontent.com/u/1", "joinedAt": 1760000000000 }Retrystorm has a small, read-only JSON API. With a learner's consent, your app can read their profile, progress, study activity and review schedule, and ask what they should do next. It can never change anything on their account.
https://retrystorm.dev. Everything is JSON over HTTPS.To start, register an app at https://retrystorm.dev/developers. You sign in with GitHub, give the app a name, the redirect URIs it will use and the scopes it may ask for, and get a client ID. That page is where you manage your apps; this site is the reference.
Four steps from nothing to the learner's data. The details of each are under Authentication.
Open https://retrystorm.dev/developers, sign in and create an app. Add a redirect URI such as https://yourapp.example/callback and pick the scopes it needs. Keep the client ID. If you choose a confidential app you also get a client secret, shown once.
Make a random code_verifier, hash it with SHA-256 and base64url-encode the hash as the code_challenge. Keep the verifier, and a random state to check on the way back.
const base64url = (bytes) =>
btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const url = new URL("https://retrystorm.dev/connect");
url.search = new URLSearchParams({
response_type: "code",
client_id: "YOUR_CLIENT_ID",
redirect_uri: "https://yourapp.example/callback",
scope: "profile:read progress:read",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
location.assign(url);The learner signs in if needed, sees what your app asks for and allows or denies it. They are sent back to your redirect URI with code and state. Check the state matches before going on.
curl -X POST https://retrystorm.dev/api/oauth/token \
-d grant_type=authorization_code \
-d code=CODE \
-d redirect_uri=https://yourapp.example/callback \
-d client_id=YOUR_CLIENT_ID \
-d code_verifier=CODE_VERIFIERThe answer holds an access_token (valid for 1 hour) and a refresh_token. Apps that keep a secret add client_secret.
curl -i -H "Authorization: Bearer ACCESS_TOKEN" https://retrystorm.dev/api/v1/progressconst res = await fetch("https://retrystorm.dev/api/v1/summary", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error((await res.json()).error);
const summary = await res.json();Retrystorm is an OAuth 2.0 authorization server. Apps use the authorization code grant with PKCE, and the challenge method is always S256, for public and confidential apps alike. An app never sees a learner's GitHub token or password.
The endpoints are also described by a metadata document (RFC 8414), so a client library can configure itself.
curl https://retrystorm.dev/.well-known/oauth-authorization-serverhttps://retrystorm.dev/connecthttps://retrystorm.dev/api/oauth/tokenhttps://retrystorm.dev/api/oauth/revokeOpen https://retrystorm.dev/connect in the learner's browser with these query parameters.
response_type: code.client_id: your app's client ID.redirect_uri: one of the URIs you registered.scope: space-separated scopes, all of them ones the app registered. Left out, the app gets everything it registered.state: a random value, sent back to you unchanged.code_challenge and code_challenge_method=S256.https://retrystorm.dev/connect?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.example/callback
&scope=profile:read%20progress:read
&state=RANDOM_STATE
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256If the learner allows it, they are sent to your redirect URI with a single-use code, valid for 10 minutes, and your state. If they deny it, you get error=access_denied instead.
https URLs, with no fragment (#) and no username or password.http is allowed only on the learner's own machine: 127.0.0.1, [::1] or localhost.http://127.0.0.1:8080/callback also matches the same address on any other port, as local apps often pick a free one.client_id or the redirect URI is wrong, the learner is not sent anywhere. They see an error page on Retrystorm instead.Post a form (or JSON) body to https://retrystorm.dev/api/oauth/token. A public app sends only client_id. A confidential app also sends its client_secret in the body, or as HTTP Basic credentials in the Authorization header.
curl -X POST https://retrystorm.dev/api/oauth/token \
-d grant_type=authorization_code \
-d code=CODE \
-d redirect_uri=https://yourapp.example/callback \
-d client_id=YOUR_CLIENT_ID \
-d code_verifier=CODE_VERIFIERThe code_verifier is 43 to 128 characters from A-Z a-z 0-9 - . _ ~, and the redirect_uri must be the one in the authorization request. A code works once: presenting it a second time fails and revokes the access it gave.
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…",
"scope": "profile:read progress:read"
}An access token lasts 1 hour (expires_in is in seconds). A refresh token lasts 90 days. Refresh tokens rotate: each refresh returns a new pair and the old refresh token stops working, so always store the newest.
curl -X POST https://retrystorm.dev/api/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_IDReuse is detected. If a refresh token that was already used comes back, for example from a stolen copy, the whole grant is revoked and the learner has to allow the app again. Refresh from one place at a time, and save the new token before using the new access token.
Revoke a token when the learner signs out of your app (RFC 7009). Revoking either token ends the access the pair belongs to. The answer is an empty 200, also for a token that does not exist. Learners can revoke an app themselves from their profile, and its tokens stop working at once.
curl -X POST https://retrystorm.dev/api/oauth/revoke \
-d token=REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_IDA scope is one kind of data a learner can allow. An app asks only for the ones it needs, never more than it registered, and the learner sees each one by name on the consent screen. All of them are read-only.
profile:readYour profileYour GitHub username, avatar and the day you joined Retrystorm.
Opens/api/v1/me
progress:readYour progressWhich chapters, challenges, labs and checkpoints you have finished, and how far you got.
Opens/api/v1/progress/api/v1/path/api/v1/next/api/v1/summary/api/v1/changes
activity:readYour activityWhich days you studied and how much, and the days you finished a review.
Opens/api/v1/activity
schedule:readYour review scheduleHow many review questions are due, when each comes back, and your interview date if you set one.
Opens/api/v1/schedule
Some endpoints leave out items for scopes the app does not hold instead of failing; each one says which. A request for something the token's scopes do not cover fails with insufficient_scope.
All endpoints are GET requests under https://retrystorm.dev/api/v1. Send the access token as a bearer token. Each endpoint needs the scope shown beside it, except /api/v1/curriculum, which is public. Times are milliseconds since the epoch and days are YYYY-MM-DD.
curl -i -H "Authorization: Bearer ACCESS_TOKEN" https://retrystorm.dev/api/v1/progressWho the learner is: login, avatar and when they joined (milliseconds since the epoch).
{ "login": "octocat", "avatar": "https://avatars.githubusercontent.com/u/1", "joinedAt": 1760000000000 }Chapters, problems, labs and checkpoints finished.
{
"updatedAt": 1763100000000,
"chapters": { "done": 12, "total": 43 },
"units": [{ "id": "caching", "completedAt": 1763000000000, "step": 6, "state": "done", "done": true }],
"problems": [{ "id": "a-fast-home-page", "bestTiers": 3, "tiersTotal": 3, "solved": true, "lastRunAt": 1763050000000 }],
"labs": [{ "id": "caching/hit-rate", "passedAt": 1762900000000 }],
"checkpoints": [{ "unit": "caching", "passedAt": 1762950000000, "score": 4 }],
"dailies": 9
}How much they did on each day over the last 400 days, and the days they finished a review.
?tz= An IANA time zone such as Europe/Paris. Today is the date now in that zone. Default: UTC.?today= A date like 2026-11-14 to use as today instead of working it out.{ "activity": { "2026-11-13": 6, "2026-11-14": 3 }, "reviewedDays": ["2026-11-13"] }Review questions waiting and when each comes due, and their interview date if set.
?tz= An IANA time zone such as Europe/Paris. Today is the date now in that zone. Default: UTC.?today= A date like 2026-11-14 to use as today instead of working it out.{
"today": "2026-11-14",
"review": { "queued": 7, "due": 2, "items": [{ "unit": "caching", "question": "hit-rate", "dueOn": "2026-11-14" }] },
"interviewBy": "2026-12-01"
}Every chapter in path order with the learner's state in it, the chapter to pick up and the one after.
{
"updatedAt": 1763100000000,
"tracks": [{ "id": "caching-and-edge", "title": "Caching and edge" }],
"current": {
"id": "cdn",
"title": "CDNs",
"minutes": 25,
"target": "lesson",
"url": "https://retrystorm.dev/learn/cdn"
},
"next": null,
"units": [
{
"id": "caching",
"title": "Caching",
"track": "caching-and-edge",
"order": 11,
"minutes": 30,
"prerequisites": ["balancing"],
"state": "done",
"step": 6,
"steps": 7,
"completedAt": 1763000000000,
"url": "https://retrystorm.dev/learn/caching",
"practice": [
{
"kind": "problem",
"id": "a-fast-home-page",
"title": "A Fast Home Page",
"done": true,
"stars": { "earned": 3, "total": 3 }
},
{
"kind": "lab",
"id": "caching/hit-rate",
"title": "Raise the hit rate",
"done": true,
"stars": { "earned": 1, "total": 1 }
}
]
}
]
}What to start next: a due review, the current chapter or its next problem or lab, today's daily question, then the chapter after.
The review needs schedule:read and the daily question needs activity:read. Without them those items are left out.
?tz= An IANA time zone such as Europe/Paris. Today is the date now in that zone. Default: UTC.?today= A date like 2026-11-14 to use as today instead of working it out.{
"today": "2026-11-14",
"next": {
"kind": "review",
"title": "Review 2 questions",
"minutes": 5,
"dueOn": "2026-11-14",
"url": "https://retrystorm.dev/review"
},
"others": [
{ "kind": "chapter", "id": "cdn", "title": "CDNs", "minutes": 25, "url": "https://retrystorm.dev/learn/cdn" },
{ "kind": "daily", "title": "Today's daily question", "minutes": 5, "dueOn": "2026-11-14", "url": "https://retrystorm.dev/daily" }
]
}A few numbers on where the learner stands, and what to do next.
The review counts and the interview date need schedule:read and are left out without it.
?tz= An IANA time zone such as Europe/Paris. Today is the date now in that zone. Default: UTC.?today= A date like 2026-11-14 to use as today instead of working it out.{
"updatedAt": 1763100000000,
"chapters": { "done": 12, "total": 43 },
"problems": { "solved": 9, "total": 46 },
"labs": { "passed": 4 },
"review": { "queued": 7, "due": 2 },
"interviewBy": "2026-12-01",
"next": { "kind": "chapter", "id": "cdn", "title": "CDNs", "minutes": 25, "url": "https://retrystorm.dev/learn/cdn" },
"lastActiveAt": 1763090000000
}What happened since a time, oldest first.
Review and daily events need activity:read and are left out without it.
?since= A time in milliseconds since the epoch. Only later events are returned. Default: 0.?limit= How many events to return, 1 to 200. Default: 100.{
"events": [
{ "kind": "lesson_passed", "at": 1763000000000, "unit": "caching" },
{ "kind": "lab_passed", "at": 1763010000000, "unit": "caching", "lab": "hit-rate" },
{
"kind": "problem_run",
"at": 1763050000000,
"problem": "a-fast-home-page",
"bestTiers": 3,
"tiersTotal": 3,
"solved": true,
"unit": "caching"
},
{ "kind": "daily_answered", "at": 1763060000000, "day": "2026-11-13", "correct": true }
],
"cursor": 1763060000000,
"hasMore": false
}The chapters the site teaches, in path order, with the problems and labs each asks for.
Public: it needs no token and is the same for everyone.
{
"tracks": [{ "id": "caching-and-edge", "title": "Caching and edge", "summary": "Answer requests without asking the database." }],
"chapters": [
{
"id": "caching",
"title": "Caching",
"summary": "Where to place a cache and how stale each copy may get.",
"track": "caching-and-edge",
"order": 11,
"minutes": 30,
"prerequisites": ["balancing"],
"steps": 7,
"url": "https://retrystorm.dev/learn/caching",
"practice": [
{ "kind": "problem", "id": "a-fast-home-page", "title": "A Fast Home Page", "requiredTier": "medium" },
{ "kind": "lab", "id": "caching/hit-rate", "title": "Raise the hit rate" }
]
}
]
}Each event has a kind, an at time and the fields that name what happened.
lesson_passed The learner passed a chapter's questions.unit_tested_out They tested out of a chapter.checkpoint_passed They passed a checkpoint, with its score.lab_passed They passed a lab.problem_run A problem's last run. Running it again moves its event later, since only the last run is kept.review_finished They finished a review on that day. Needs activity:read.daily_answered They answered that day's daily question. Needs activity:read.No event says a chapter became done, because that depends on its problems and labs too. Read /api/v1/path again to learn it.
Every successful data response carries an ETag. Keep it and send it back as If-None-Match. If nothing changed you get an empty 304 with no body, which is cheap to send and to handle. Browser apps can read the header, since it is exposed to cross-origin scripts.
curl -i -H "Authorization: Bearer ACCESS_TOKEN" \
-H 'If-None-Match: "ETAG_FROM_LAST_RESPONSE"' \
https://retrystorm.dev/api/v1/progressThe learner endpoints answer with Cache-Control: no-store, so no cache in between keeps them. Only /api/v1/curriculum is public and may be cached for 5 minutes.
expires_in runs out, or when a call answers 401 with invalid_token.Events come oldest first. Start with since=0 (or the time you connected), then send the returned cursor back as since. While hasMore is true there is more waiting, so ask again at once. When it is false, wait before the next poll. A page never stops between events with the same time, so it can hold more than limit when many share one. When nothing is new the cursor stays where it was.
let since = 0;
async function poll(accessToken) {
for (;;) {
const res = await fetch(`https://retrystorm.dev/api/v1/changes?since=${since}&limit=100`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const { events, cursor, hasMore } = await res.json();
for (const event of events) handle(event);
since = cursor;
if (!hasMore) return;
}
}Save the cursor with your own state, so a restart carries on where it stopped.
These answer in the OAuth error format (RFC 6749): a 400, or a 401 for a client that is unknown or has the wrong secret, with the fields below.
{
"error": "invalid_grant",
"error_description": "The code is wrong or expired."
}invalid_request: a required parameter is missing or malformed.invalid_client: the client is unknown or its secret is wrong. Comes with a WWW-Authenticate: Basic header.invalid_grant: the code or refresh token is wrong, expired, used before or revoked, or the verifier or redirect URI does not match.unsupported_grant_type: use authorization_code or refresh_token.Problems in the authorization request that can be told to your app come back on the redirect URI as error, error_description and your state: unsupported_response_type, invalid_request, invalid_scope and access_denied when the learner says no.
A bearer error is a JSON body with error and a message to show a developer, and a WWW-Authenticate header (RFC 6750).
WWW-Authenticate: Bearer
{ "error": "invalid_token", "message": "A bearer access token is required." }WWW-Authenticate: Bearer error="invalid_token"
{ "error": "invalid_token", "message": "The access token is invalid or expired." }WWW-Authenticate: Bearer error="insufficient_scope", scope="progress:read"
{ "error": "insufficient_scope", "message": "This needs the progress:read scope." }401: refresh the access token, and if that fails send the learner through authorization again.403: the learner did not allow that scope. Ask for it with a new authorization request.400 with invalid_request: a query value is wrong, such as a since that is not a whole number of milliseconds. The message says which.503 with unavailable: the curriculum could not be read right now. Try again shortly.