Retrystorm API

Overview

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.

  • Base URL: https://retrystorm.dev. Everything is JSON over HTTPS.
  • Access is granted by the learner through OAuth 2.0 (authorization code with PKCE), one scope per kind of data.
  • The data endpoints and the token endpoints answer cross-origin requests, so a browser app can call them directly.
  • Learners see every app they allowed under Connected apps on their profile, and can revoke any of them.

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.

Quickstart

Four steps from nothing to the learner's data. The details of each are under Authentication.

1. Register an app

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.

2. Send the learner to approve it

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.

JavaScript
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.

3. Exchange the code for tokens

curl
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_VERIFIER

The answer holds an access_token (valid for 1 hour) and a refresh_token. Apps that keep a secret add client_secret.

4. Call the API

curl
curl -i -H "Authorization: Bearer ACCESS_TOKEN" https://retrystorm.dev/api/v1/progress
JavaScript
const 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();

Authentication

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.

Metadata

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-server
  • Authorization endpoint: https://retrystorm.dev/connect
  • Token endpoint: https://retrystorm.dev/api/oauth/token
  • Revocation endpoint: https://retrystorm.dev/api/oauth/revoke

Authorization request

Open 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=S256

If 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.

Redirect URIs

  • An app registers up to 5. They must be absolute https URLs, with no fragment (#) and no username or password.
  • Plain http is allowed only on the learner's own machine: 127.0.0.1, [::1] or localhost.
  • The URI in a request must match a registered one exactly. The one exception is loopback: a registered http://127.0.0.1:8080/callback also matches the same address on any other port, as local apps often pick a free one.
  • If the client_id or the redirect URI is wrong, the learner is not sent anywhere. They see an error page on Retrystorm instead.

Token request

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
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_VERIFIER

The 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.

200 OK
{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "…",
  "scope": "profile:read progress:read"
}

Refreshing

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
curl -X POST https://retrystorm.dev/api/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID

Reuse 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.

Revoking

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
curl -X POST https://retrystorm.dev/api/oauth/revoke \
  -d token=REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID

Scopes

A 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 profile

    Your GitHub username, avatar and the day you joined Retrystorm.

    Opens/api/v1/me

  • progress:readYour progress

    Which 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 activity

    Which days you studied and how much, and the days you finished a review.

    Opens/api/v1/activity

  • schedule:readYour review schedule

    How 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.

Endpoints

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
curl -i -H "Authorization: Bearer ACCESS_TOKEN" https://retrystorm.dev/api/v1/progress

GET /api/v1/meprofile:read

Who the learner is: login, avatar and when they joined (milliseconds since the epoch).

200 OK, trimmed
{ "login": "octocat", "avatar": "https://avatars.githubusercontent.com/u/1", "joinedAt": 1760000000000 }

GET /api/v1/progressprogress:read

Chapters, problems, labs and checkpoints finished.

200 OK, trimmed
{
  "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
}

GET /api/v1/activityactivity:read

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.
200 OK, trimmed
{ "activity": { "2026-11-13": 6, "2026-11-14": 3 }, "reviewedDays": ["2026-11-13"] }

GET /api/v1/scheduleschedule:read

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.
200 OK, trimmed
{
  "today": "2026-11-14",
  "review": { "queued": 7, "due": 2, "items": [{ "unit": "caching", "question": "hit-rate", "dueOn": "2026-11-14" }] },
  "interviewBy": "2026-12-01"
}

GET /api/v1/pathprogress:read

Every chapter in path order with the learner's state in it, the chapter to pick up and the one after.

200 OK, trimmed
{
  "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 }
        }
      ]
    }
  ]
}

GET /api/v1/nextprogress:read

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.
200 OK, trimmed
{
  "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" }
  ]
}

GET /api/v1/summaryprogress:read

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.
200 OK, trimmed
{
  "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
}

GET /api/v1/changesprogress:read

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.
200 OK, trimmed
{
  "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
}

GET /api/v1/curriculumno token

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.

200 OK, trimmed
{
  "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" }
      ]
    }
  ]
}

Events in /changes

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.

Polling and caching

ETag

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
curl -i -H "Authorization: Bearer ACCESS_TOKEN" \
  -H 'If-None-Match: "ETAG_FROM_LAST_RESPONSE"' \
  https://retrystorm.dev/api/v1/progress

The 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.

Token lifetime

  • An access token lasts 1 hour. Refresh a little before expires_in runs out, or when a call answers 401 with invalid_token.
  • A refresh token lasts 90 days and is replaced on every refresh. See Refreshing.
  • If a refresh fails with invalid_grant, the learner revoked the app or the token was reused. Ask them again.

Following /changes

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.

JavaScript
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.

Errors

Token and revoke endpoints

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.

400 Bad Request
{
  "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.

Data endpoints

A bearer error is a JSON body with error and a message to show a developer, and a WWW-Authenticate header (RFC 6750).

401 Unauthorized, no token
WWW-Authenticate: Bearer

{ "error": "invalid_token", "message": "A bearer access token is required." }
401 Unauthorized, expired or revoked token
WWW-Authenticate: Bearer error="invalid_token"

{ "error": "invalid_token", "message": "The access token is invalid or expired." }
403 Forbidden, missing scope
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.