GuidesPROFILE APIS
Duolingo API in browser JavaScript
Duolingo has no official public API, but the undocumented www.duolingo.com/2017-06-30/users?username= endpoint returns any public profile's streak, total XP and courses without a login. On 28 October 2026 it answered 200 with 5,134 bytes and no Access-Control-Allow-Origin header, so fetch() from your page fails. The same URL through the free cors.dev proxy returned 200 with no signup, and a fields filter cut the response to 44 bytes.
Why fetch() to duolingo.com fails
Duolingo has no official public API and publishes no API documentation; developers.duolingo.com redirects to the home page. Profile data comes from an undocumented endpoint that can change or disappear without notice.
On 28 October 2026 GET https://www.duolingo.com/2017-06-30/users?username=luis answered 200 with 5,134 bytes of JSON but no Access-Control-Allow-Origin header, and a preflight answered 204, also without it. The browser hides the response and the console shows "blocked by CORS policy". Through the free cors.dev proxy (https://proxy.cors.dev/ plus the full URL) the same request returned the same 5,134 bytes with the header set to the calling origin.
| Request | Direct from a page | Through proxy.cors.dev |
|---|---|---|
| 2017-06-30/users?username=luis | 200, 5,134 bytes, no CORS header | 200, 5,134 bytes |
| Same with fields=users{streak,totalXp} | 200, 44 bytes, no CORS header | 200, 44 bytes |
| 2017-06-30/users?username= unknown name | 200, empty users array, no CORS header | 200, empty users array |
| 2017-06-30/users/14 by ID | 403 without a login | 401 |
| api/1/users/show?username=luis | 401 HTML page | 401 |
Without a login the by-ID form answered 403 from a page and 401 through the proxy, and the old /api/1 endpoint 401, so the username lookup is the one to use without a login.
Fields for a streak widget
The response is {"users":[...]} with one user object, 38 fields for luis. The lookup is case-insensitive ("luis" returned "Luis"), and an unknown username still answers 200 with {"users":[]}.
| Field | What it holds | luis on 28 October 2026 |
|---|---|---|
| streak | Current streak in days, 0 when broken | 3,987 |
| streakData.currentStreak | length, startDate and endDate, null with no streak | 2019-07-25 to 2026-10-28 |
| totalXp | All XP on the account | 395,011 |
| courses | Language courses with title, learningLanguage and xp | 18 courses, French highest at 87,079 XP |
| currentCourseId | Course in progress | CHESS_CH |
| picture | Avatar URL with no scheme or size | Needs https: and a size such as /large |
| creationDate | Signup time in Unix seconds | 1322621037, 30 November 2011 |
Quirks to handle:
- Use
totalXpfor the total. For luis the 18 courses summed to 171,799 XP against atotalXpof 395,011, and for severin 80,353 against 98,439. Both were onCHESS_CH, which is not incourses; for two accounts with only language courses the sums matched. crownsread 9999 on every course, so it carries no information.streakData.currentStreakisnullwhenstreakis 0.pictureneedshttps:in front and a size suffix; the bare URL answered400. The PNG sizes were/small1,497 bytes,/medium3,950,/large7,934,/xlarge18,043 and/xxlarge90,396. The default avatar,//simg-ssl.duolingo.com/avatar/default_2, needs the suffix too (/largereturned a 6,271-byte JPEG).
Trim the response with fields
The fields parameter trims the response, but only inside a users{...} wrapper. fields=users{streak,totalXp} returned 44 bytes, {"users":[{"streak":3987,"totalXp":395011}]}, and fields=users{username,streak,totalXp,picture,courses{title,learningLanguage,xp}} returned 1,350 bytes, directly and through the proxy.
Without the wrapper, fields=streak,totalXp,courses returned {}. A value with unbalanced braces was ignored and all 5,134 bytes came back, so check the size of what you get.
Streak badge through the free proxy
The free proxy needs no signup and allows GET and HEAD at about 600 requests a minute per IP, with no caching and no cookies forwarded. The snippet makes one GET with a trimmed fields list, throws on an unknown username, builds the avatar URL from picture and writes streak, XP and the top 3 courses into the page. On 28 October 2026 it printed "3987-day streak, 395,011 XP, French, Portuguese, Spanish".
const username = 'luis';
const params = new URLSearchParams({
username,
fields: 'users{username,streak,totalXp,picture,courses{title,xp}}',
});
const response = await fetch(`https://proxy.cors.dev/https://www.duolingo.com/2017-06-30/users?${params}`);
if (!response.ok) throw new Error(`${response.status} ${response.headers.get('X-Cors-Error') ?? ''}`);
// An unknown username still answers 200, with an empty list
const [user] = (await response.json()).users ?? [];
if (!user) throw new Error(`No Duolingo user named ${username}`);
const topCourses = [...user.courses]
.sort((a, b) => b.xp - a.xp)
.slice(0, 3)
.map((course) => course.title);
// picture has no scheme and needs a size suffix
document.querySelector('#duo-avatar').src = `https:${user.picture}/large`;
document.querySelector('#duo-streak').textContent = [
`${user.streak}-day streak`,
`${user.totalXp.toLocaleString('en-US')} XP`,
...topCourses,
].join(', ');Leaderboard, caching and limits
For a study group, fetch each name in parallel with Promise.allSettled and sort by streak; a missing name is reported without sinking the board. On the test date it ranked Luis 3,987, severin 2,343 and duo 0, and flagged the made-up name as not found.
const friends = ['luis', 'severin', 'duo', 'not-a-real-user-123'];
async function streakOf(username) {
const params = new URLSearchParams({ username, fields: 'users{username,streak,totalXp}' });
const response = await fetch(`https://proxy.cors.dev/https://www.duolingo.com/2017-06-30/users?${params}`);
if (!response.ok) throw new Error(`${username}: ${response.status}`);
const [user] = (await response.json()).users ?? [];
if (!user) throw new Error(`${username}: not found`);
return user;
}
// One missing or failing name should not sink the whole board
const results = await Promise.allSettled(friends.map(streakOf));
const board = results
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value)
.sort((a, b) => b.streak - a.streak || b.totalXp - a.totalXp);
for (const { username, streak, totalXp } of board) console.log(username, streak, totalXp);
for (const result of results) if (result.status === 'rejected') console.warn(result.reason.message);Cache in the page, not in the proxy. Duolingo sends Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, Expires: 0 and a Set-Cookie header, and cors.dev never caches a response marked no-store or one that sets a cookie, so X-Cors-Cache on cors.dev Pro reports BYPASS. Keep results in localStorage for an hour instead; a streak changes at most once a day.
Duolingo publishes no rate limit. In our test 60 direct requests, 10 at a time, all returned 200, and so did 20 parallel requests through the free proxy. The free tier is enough here: cors.dev Pro ($5 a month for 500,000 requests, with a 7-day trial of 1,000 requests and no card at cors.dev/signup) adds POST and forwarding of Authorization and custom headers at 3,000 requests a minute per account and 100 in flight, which a public profile lookup does not need.
Duolingo's terms
Section 17 of Duolingo's Terms and Conditions (last revised 8 September 2025) says: "You may not use any data mining, robots, scraping, or similar data gathering or extraction methods to obtain Service Content." A badge that reads your own public profile a few times a day is not a crawler, but Duolingo decides what counts, so keep requests few and cached, read only profiles you have a reason to show, and never automate lessons or logins.
Anything tied to an account, such as lessons, settings and friends lists, needs a Duolingo login session. Never put session cookies or tokens in page code and never send them through any proxy.
Good questions.
Does Duolingo have an official API?
No. Duolingo publishes no public API or documentation, and developers.duolingo.com redirects to the home page. Public profiles can be read from the undocumented /2017-06-30/users?username= endpoint, which can change without notice.
How do I get my Duolingo streak with JavaScript?
Build username and fields=users{streak,totalXp} with URLSearchParams, fetch https://www.duolingo.com/2017-06-30/users through https://proxy.cors.dev/ and read users[0].streak. Check that users is not empty first, because an unknown username also returns 200.
Why does fetch to duolingo.com fail with a CORS error?
Duolingo answers 200, and 204 to preflights, without an Access-Control-Allow-Origin header, so the browser hides the response from your script. The same URL through https://proxy.cors.dev/ comes back with the header set to your origin.
How do I get a Duolingo user's total XP and languages?
Ask for fields=users{totalXp,courses{title,learningLanguage,xp}}, which returned 995 bytes for luis, and read totalXp for the total. Do not add up courses: on two accounts whose current course was CHESS_CH, the course XP summed to far less than totalXp.
Why does my Duolingo avatar URL not load?
picture has no scheme and no size, and the bare URL answered 400. Prepend https: and append /small, /medium, /large, /xlarge or /xxlarge; the default avatar URL needs the same suffix.
Can I use the Duolingo API to log in or complete lessons?
Not from a page. Account actions need a Duolingo login session, which belongs in Duolingo's own apps, never in page code or a proxy, and section 17 of Duolingo's terms bans robots and automated data gathering.