Overview
Autodemia is an OAuth 2.0 provider. Your app sends someone to Autodemia, they say yes, and your server gets tokens to read their public profile. It's the same idea as "Sign in with GitHub", for learning.
The one rule: an app never sees more than a stranger could see on the person's public profile at autodemia.io/u/<handle>. No email, no answers, chats, notes or uploads, and no names of private pursuits. If their profile is off, you get their name and username and nothing else.
| Method | Path | What it does |
|---|---|---|
| GET | /oauth/authorize | The consent page. Send the person here in their browser. |
| POST | /oauth/token | Trade a code for tokens, or refresh them. |
| POST | /oauth/revoke | Revoke an access or refresh token. |
| GET | /api/v1/me | The connected person's public profile, cut to the scopes they allowed. |
| GET | /.well-known/oauth-authorization-server | OAuth server metadata (RFC 8414). |
| GET | /developers/openapi.json | This API as an OpenAPI 3.1 document. |
Every app needs a server: the token endpoint always takes your client secret, and the API doesn't allow calls from browsers on other sites. Keep the secret and tokens on your server.
Register your app
Apps are registered by the Autodemia team for now. Email hello@autodemia.io with:
- Your app's name, as people will see it on the consent screen.
- Its homepage.
- Your redirect URLs, up to ten. Each must be
https, orhttp://localhost(or127.0.0.1) while you develop. No#fragmentsand no logins in the URL.
You'll get a client ID (adc_…) and a client secret (ads_…). The secret is shown once; if it leaks, ask us to rotate it and the old one stops working at once.
Redirect URLs match exactly, character for character. https://yourapp.com/callback and https://yourapp.com/callback/ are different addresses.
Connect an account
The authorization code flow, with PKCE. PKCE is optional for apps with a server, but we recommend it for every app.
1Make a PKCE verifier and a state
Per sign-in, make a random verifier (43 to 128 characters of letters, digits, -._~), its SHA-256 challenge in base64url, and a random state. Keep both in the person's session.
import crypto from 'node:crypto'
// Make these per sign-in and keep them in the person's session.
const verifier = crypto.randomBytes(32).toString('base64url') // 43 characters
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
const state = crypto.randomBytes(16).toString('base64url')2Send them to Autodemia
Redirect the browser to /oauth/authorize. Separate scopes with spaces; profile is always included.
https://autodemia.io/oauth/authorize
?client_id=adc_YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fyourapp.com%2Fauth%2Fautodemia%2Fcallback
&response_type=code
&scope=activity%20pursuits%20badges%20latest
&state=RANDOM_STATE
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256They sign in if they need to, see exactly what your app will see, and press Allow. Someone who already allowed every scope you ask for goes straight back without seeing the page again. Child profiles can't connect.
3Handle the callback
Autodemia sends them back to your redirect_uri. Check that state matches what you saved before doing anything else.
https://yourapp.com/auth/autodemia/callback?code=Q2hlY2sgdGhlIHN0YXRl…&state=RANDOM_STATE
# or, if they pressed "Not now":
https://yourapp.com/auth/autodemia/callback?error=access_denied&error_description=The+person+said+no&state=RANDOM_STATE4Trade the code for tokens
From your server, within ten minutes. A code works once. Send your client ID and secret as HTTP Basic (shown) or as client_id and client_secret in the body. Form-encoded or JSON bodies both work.
curl -X POST https://autodemia.io/oauth/token \
-u "adc_YOUR_CLIENT_ID:ads_YOUR_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=Q2hlY2sgdGhlIHN0YXRl… \
-d redirect_uri=https://yourapp.com/auth/autodemia/callback \
-d code_verifier=YOUR_VERIFIER{
"access_token": "ada_k3Jx…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "adr_9fQe…",
"scope": "profile activity pursuits badges latest"
}5Read their profile
Call GET /api/v1/me with the access token. Save user.id with your own user record: that's how you know who connected.
Scopes
Ask only for what you'll show. The consent screen lists each one in plain words, as below.
| Scope | What the person sees | Field in /me |
|---|---|---|
profilealways | Your name, @username, photo, bio and website | user |
activity | Your activity grid, hours studied and streak, as numbers | activity |
pursuits | Your public pursuits and your progress in them | pursuits |
badges | The badges you've earned | badges |
latest | Concepts you recently got solid or mastered, from public pursuits | latest |
Need more later? Send them through /oauth/authorize again with the extra scopes. They'll see the page once more, and new tokens carry the scopes of that sign-in.
GET /api/v1/me
What the connected person's public profile shows, cut to the scopes they allowed. Send the access token as a Bearer token.
| Query | What it does |
|---|---|
year | Optional. A calendar year (like 2026) for the activity grid, instead of the last twelve months. |
curl https://autodemia.io/api/v1/me \
-H "Authorization: Bearer ada_k3Jx…"
# A calendar year's grid instead of the last twelve months:
curl "https://autodemia.io/api/v1/me?year=2026" -H "Authorization: Bearer ada_k3Jx…"{
"user": {
"id": "5c1e0b8f2a7d4e6c9b3a1f0d8e7c6b5a",
"handle": "johnmc",
"name": "John",
"public": true,
"url": "https://autodemia.io/u/johnmc",
"avatar": "https://autodemia.io/avatars/…/a.jpg",
"bio": "Learning C the hard way.",
"website": "https://example.com",
"joinedAt": 1761696000000
},
"scopes": ["profile", "activity", "pursuits", "badges", "latest"],
"activity": {
"from": "2025-11-12", "to": "2026-11-11", "year": null, "years": [2025, 2026],
"total": 6467, "last7Days": 340, "activeDays": 210, "hours": 182.5,
"dayStreak": 52, "weekStreak": 30,
"conceptsLearned": 140, "conceptsMastered": 61,
"days": [
{ "day": "2025-11-12", "count": 4, "level": 2 },
{ "day": "2025-11-13", "count": 0, "level": 0 }
]
},
"pursuits": {
"list": [{
"title": "Modern C", "goal": "Write and debug real C programs",
"readiness": 0.72, "concepts": 80, "solid": 40, "mastered": 22,
"lastActive": 1762800000000, "startedAt": 1761700000000,
"url": "https://autodemia.io/explore?open=…"
}],
"privateCount": 2
},
"badges": [
{ "id": "steady", "name": "Steady", "blurb": "Met the weekly goal week after week",
"level": 3, "label": "12 weeks", "earnedAt": 1762000000000 }
],
"latest": [
{ "concept": "Pointers", "pursuit": "Modern C", "level": "Mastered", "at": 1762800000000 }
]
}With the profile off, or before they've picked a username, you get only this, whatever scopes they allowed:
{
"user": {
"id": "5c1e0b8f2a7d4e6c9b3a1f0d8e7c6b5a",
"handle": "johnmc",
"name": "John",
"public": false,
"url": "https://autodemia.io/u/johnmc"
},
"scopes": ["profile", "activity", "pursuits", "badges", "latest"]
}Times are epoch milliseconds. Dates are YYYY-MM-DD in the person's own time zone. Fields for scopes they didn't allow are left out, not null.
user
id- Stable for this person in your app, and different in every other app, so apps can't match people up. Link accounts with this, never the handle.
handle- Their @username, or null if they haven't picked one. People can change it.
name- Their display name.
public- Whether their public profile is on. When false, nothing below this comes back.
url- Their public profile page, or null without a username.
avatar, bio, website- As shown on their profile. Each may be null or empty.
joinedAt- When they joined.
activity
from, to- The dates the grid covers: the last twelve months, or the calendar year you asked for.
year, years- The year you asked for (or null), and every year with activity, for a year picker.
days[]- One entry per day: day (YYYY-MM-DD), count, and level from 0 to 4, shaded against their own busiest days, like GitHub's grid.
total, last7Days- Answers, reviews, lessons, practice and questions to the Assistant, as counts only.
activeDays, hours- Days with any activity, and hours of study time.
dayStreak- Days in a row up to today. null when you pass ?year.
weekStreak- Autodemia's own streak: weeks in a row meeting their weekly goal.
conceptsLearned, conceptsMastered- Across all their pursuits, as numbers.
pursuits, badges, latest
pursuits.list[]- Public pursuits only, most recently studied first: title, goal, readiness (0 to 1), concepts, solid, mastered, lastActive, startedAt, url.
pursuits.privateCount- How many pursuits they keep private. Never their names.
badges[]- Earned badges: id, name, blurb, level, label (like "12 weeks") and earnedAt.
latest[]- Up to ten concepts they most recently got Solid or Mastered, newest first, from public pursuits only.
Tokens and refresh
- Codes work once, for ten minutes.
- Access tokens (
ada_…) last 60 minutes (expires_in: 3600). - Refresh tokens (
adr_…) last until the person disconnects, but each one works once.
Every refresh returns a new access token and a new refresh token. Store the new refresh token straight away. If an old refresh token is ever used again, Autodemia assumes it leaked and ends every token for that connection; the person then has to connect again.
curl -X POST https://autodemia.io/oauth/token \
-u "adc_YOUR_CLIENT_ID:ads_YOUR_CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token=adr_9fQe…The response looks just like the one in step 4. If two of your servers might refresh at once, let only one do it (a lock, or a single job) so you never reuse a refresh token by accident.
Disconnecting
When someone unlinks Autodemia in your app, revoke the refresh token (that's enough; revoking an access token works too). It follows RFC 7009, so an unknown or already revoked token still returns 200.
curl -X POST https://autodemia.io/oauth/revoke \
-u "adc_YOUR_CLIENT_ID:ads_YOUR_CLIENT_SECRET" \
-d token=adr_9fQe…People can also disconnect your app any time from You > Connected apps in Autodemia. Its tokens stop working at once and your next call gets a 401. Treat a 401 that a refresh doesn't fix as "disconnected", and offer to connect again.
Errors
The token and revoke endpoints return standard OAuth errors (RFC 6749 §5.2). Errors on the consent page come back to your redirect_uri as ?error=…&error_description=…&state=….
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "invalid_grant",
"error_description": "That code is expired or already used"
}The API returns an HTTP status with a short message:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": true,
"url": "https://autodemia.io/api/v1/me",
"statusCode": 401,
"statusMessage": "Send a valid Autodemia access token as a Bearer token",
"message": "Send a valid Autodemia access token as a Bearer token"
}| Where | Error | When |
|---|---|---|
| Consent page | Shown on the page | Unknown client_id, or a redirect_uri you didn't register. Autodemia never redirects to an address it doesn't know. |
| Redirect | access_denied | The person pressed "Not now". |
| Redirect | unsupported_response_type | response_type wasn't code. |
| Redirect | invalid_request | code_challenge_method wasn't S256, or code_challenge isn't a 43-character base64url SHA-256. |
| Token, 401 | invalid_client | Unknown client_id, a removed app, or the wrong secret. |
| Token, 400 | invalid_request | code or refresh_token is missing. |
| Token, 400 | invalid_grant | The code is expired, already used, or for another app; redirect_uri or code_verifier doesn't match; the refresh token isn't valid or was already used; or the account can no longer connect. |
| Token, 400 | unsupported_grant_type | grant_type wasn't authorization_code or refresh_token. |
| API, 401 | Unauthorized | No token, an expired or revoked token, or the person disconnected your app. Refresh once; if that fails, ask them to connect again. |
| Any, 429 | Too Many Requests | Over a rate limit. Wait a minute and try again. |
Rate limits
/api/v1/me: 60 calls a minute for each connected person./oauth/token: 300 calls a minute for each app.
Over a limit you get 429; wait a minute. Profiles change slowly, so cache /me for a few minutes rather than calling it on every page view.
Privacy rules
These are built into the API, so you can promise them to your own users too:
- You see only what the person's public profile shows, and only for the scopes they allowed.
- Profile off, or no username yet: name and username only.
- Never their email, answers, chats with the Assistant, notes, uploads or cards.
- Never the names of private pursuits, pursuits from an organization, or ones a parent or teacher set up. You get a count of private pursuits, nothing more.
- Children's profiles can't connect.
user.idis different in every app, so two apps can't match the same person up.- They can see and disconnect your app at any time.
On your side: keep tokens and the client secret on your server, store refresh tokens encrypted, ask only for the scopes you show, and delete what you stored when someone disconnects. See our privacy policy.
OpenAPI and discovery
The whole API is described in an OpenAPI 3.1 document at a stable address. Load it into Postman, Insomnia or a code generator.
https://autodemia.io/developers/openapi.json- The same document at
https://autodemia.io/api/v1/openapi.json.
OAuth libraries that support discovery (RFC 8414) can find the endpoints, scopes and supported methods on their own:
curl https://autodemia.io/.well-known/oauth-authorization-serverAny standard OAuth 2.0 client library works. Use the authorization code grant, S256 PKCE, and client_secret_basic or client_secret_post.
Full example
A small Node app with Express that connects an account and reads the profile. The same steps work in any language.
// A tiny Express app: "Connect Autodemia", the callback, and reading the profile.
import crypto from 'node:crypto'
import express from 'express'
import session from 'express-session'
const AUTODEMIA = 'https://autodemia.io'
const CLIENT_ID = process.env.AUTODEMIA_CLIENT_ID // adc_…
const CLIENT_SECRET = process.env.AUTODEMIA_CLIENT_SECRET // ads_…, server only
const REDIRECT_URI = 'https://yourapp.com/auth/autodemia/callback'
const app = express()
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }))
app.get('/auth/autodemia', (req, res) => {
const verifier = crypto.randomBytes(32).toString('base64url')
const state = crypto.randomBytes(16).toString('base64url')
req.session.autodemia = { verifier, state }
const url = new URL('/oauth/authorize', AUTODEMIA)
url.search = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'activity pursuits badges latest',
state,
code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'),
code_challenge_method: 'S256',
}).toString()
res.redirect(url.toString())
})
app.get('/auth/autodemia/callback', async (req, res) => {
const saved = req.session.autodemia
if (!saved || req.query.state !== saved.state) return res.status(400).send('Bad state')
if (req.query.error) return res.redirect('/settings?autodemia=cancelled')
const r = await fetch(`${AUTODEMIA}/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: String(req.query.code),
redirect_uri: REDIRECT_URI,
code_verifier: saved.verifier,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
})
const tokens = await r.json()
if (!r.ok) return res.status(400).send(tokens.error_description)
const me = await fetch(`${AUTODEMIA}/api/v1/me`, {
headers: { authorization: `Bearer ${tokens.access_token}` },
}).then(x => x.json())
// Save me.user.id with your user, and keep the refresh token encrypted.
await saveConnection(req.session.userId, me.user.id, tokens)
res.redirect('/settings?autodemia=connected')
})Help
Questions, a new redirect URL, a rotated secret, or something the API should return? Email hello@autodemia.io. The API is versioned under /api/v1; we'll only add fields there, never remove or rename them without a new version.