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.

Duolingo profile requests, checked October 2026
RequestDirect from a pageThrough proxy.cors.dev
2017-06-30/users?username=luis200, 5,134 bytes, no CORS header200, 5,134 bytes
Same with fields=users{streak,totalXp}200, 44 bytes, no CORS header200, 44 bytes
2017-06-30/users?username= unknown name200, empty users array, no CORS header200, empty users array
2017-06-30/users/14 by ID403 without a login401
api/1/users/show?username=luis401 HTML page401

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":[]}.

Duolingo user fields for a streak widget, checked October 2026
FieldWhat it holdsluis on 28 October 2026
streakCurrent streak in days, 0 when broken3,987
streakData.currentStreaklength, startDate and endDate, null with no streak2019-07-25 to 2026-10-28
totalXpAll XP on the account395,011
coursesLanguage courses with title, learningLanguage and xp18 courses, French highest at 87,079 XP
currentCourseIdCourse in progressCHESS_CH
pictureAvatar URL with no scheme or sizeNeeds https: and a size such as /large
creationDateSignup time in Unix seconds1322621037, 30 November 2011

Quirks to handle:

  • Use totalXp for the total. For luis the 18 courses summed to 171,799 XP against a totalXp of 395,011, and for severin 80,353 against 98,439. Both were on CHESS_CH, which is not in courses; for two accounts with only language courses the sums matched.
  • crowns read 9999 on every course, so it carries no information.
  • streakData.currentStreak is null when streak is 0.
  • picture needs https: in front and a size suffix; the bare URL answered 400. The PNG sizes were /small 1,497 bytes, /medium 3,950, /large 7,934, /xlarge 18,043 and /xxlarge 90,396. The default avatar, //simg-ssl.duolingo.com/avatar/default_2, needs the suffix too (/large returned 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".

Duolingo streak badge through the free proxy
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.

Duolingo streak leaderboard for a study group
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.