Jake Goldsborough

Reverse Engineering UDisc's API - Part 1, Courses

Published May 28, 2025

6 min read

Tags: APIs, typescript

I am a very avid fan and player of disc golf.

Disc golf is basically the rules of golf but you're throwing a frisbee instead of hitting a ball. There are differences of course. We don't throw the disc into a hole in the ground, but the basic idea of starting in one place and getting an object to another remains the same. It's a beautiful sport that combines the mystique of physics and flight with the beauty and tranquility of nature. I get to take a hike and throw stuff through the woods, what's not to love? Like golf, there is a scoring system, and that's where UDisc comes in.

UDisc is a great site, app, and company who makes the go-to disc golf scoring app. You can search for courses, find leagues, and most importantly, keep score. It's a fantastic tool with one problem - there is no public API. I have seen this asked for publicly but UDisc has shown no plans for this in the immediate future, that's where I come in. I really like figuring out how things work.

Starting small

This is the first real API I've tried reverse engineering so I wanted to start small. Some of the features of UDisc are paid and only available in the app and I didn't want to dive into that just yet. There are parts of the service that are available on the website so I wanted to start there.

Courses

The biggest feature of the free version of the app is probably the course directory so that's where I started.

https://udisc.com/courses

Going directly to the courses URL, you can use the Developer Tools and the Network tab to already find some useful calls. The most important is a call to /__manifest. This seems to strongly suggest at least the site is a Remix Run based app, also indidcated by the p parameters.

https://remix.run/

The full URL looks like this:

GET
	https://udisc.com/__manifest?p=/courses&p=/courses/carey-park-disc-golf-course-e2a5&p=/courses/cheney-state-park-disc-golf-course-west-DnNL&p=/courses/chisholm-trail-3RqD&p=/courses/cowskin-mKI4&p=/courses/cowskin-west-MXwZ&p=/courses/dragon-disc-golf-course-ePkd&p=/courses/flying-bee-country-club-WOm5&p=/courses/garden-plain-city-park-dgc-dsry&p=/courses/helten-gardens-Bupy&p=/courses/keith-l-christner-family-dg-course-kLoh&p=/courses/lazys-links-1S2k&p=/courses/liberty-links-disc-golf-course-uwbs&p=/courses/linear-park-0KnA&p=/courses/llama-loop-qBK4&p=/courses/maize-south-dgc-0eco&p=/courses/newman-university-9Fmt&p=/courses/orchard-disc-golf-course-7F1E&p=/courses/prairie-winds-disk-golf-course-LiKg&p=/courses/quail-run-gFli&p=/courses/west-millbrook-dgc-tDG5&version=149b0717

While this does tell us a lot, it doesn't give us any real data. If we try changing filters, we see another useful call.

GET https://udisc.com/courses.data?sortOrder=rating

Fore! Data incoming. While it's very, very obfuscated, you can see some real data in there.

We also can confirm our suspicions by looking at the response headers:

x-remix-response yes

Most definitely a Remix based app.

Parsing The Data

Looking at the data itself, it looks JSON-y, but when trying to parse it, we find it's corrupt and it fails. There are some foreign chars between two valid JSON arrays. This seems intentional as it's not breaking their site so they must know about it? Regardless, let's scrub that out.

This function takes our raw string and iterates over it creating a stack and checking for valid opening JSON brackets. When it finds an opening bracket, it pushes it to the stack, and when it finds a closing bracket, it pops one off. When the stack is empty, we know the JSON structure is complete.

export function extractJsonChunks(raw: string): any[] {
  const chunks: any[] = [];
  let i = 0;

  while (i < raw.length) {
    if (raw[i] === '[' || raw[i] === '{') {
      const start = i;
      const stack = [raw[i]];
      i++;

      while (i < raw.length && stack.length > 0) {
        if (raw[i] === '[' || raw[i] === '{') {
          stack.push(raw[i]);
        } else if (raw[i] === ']' || raw[i] === '}') {
          stack.pop();
        }
        i++;
      }

      const chunk = raw.slice(start, i);
      try {
        chunks.push(JSON.parse(chunk));
      } catch {
        // ignore invalid chunks
      }
    } else {
      i++;
    }
  }

  return chunks;
}

Inspecting The Data

After successfully parsing the data, and there is a lot of it, we can take a better look at two very large arrays.

The first part of the first array is mostly config looking stuff - API tokens for various services and basic user settings like locale.

My interest is mostly in the actual data, which you can kind of see in the snippet below. These are items in the array, so you can tell the data is there but it's very confusing to read and code against. It would be easy to assume a key then a value but then you see things like price, followed by status and can tell price is obviously missing a value.

"_id",
"cxYvYpWXX66MoAG5f",
"name",
"Orchard Disc Golf Course",
"headline",
"Moderately wooded technical course, Par 54 unless hole 5 is in long., making that a par 4 hole. (course 55)",
"longDescription",
"18 holes, 1 tee for each hole, 2 basket locations for each hole, rotated regularly.  ",
"holeCount",
18,
"price",
"status",
"Active",
"website",
"https://www.facebook.com/groups/270758746431057",

Before any of that actual data, there is an array which didn't make much sense at first.

After some time digging and debugging (and a little help from ChatGPT), it clicked into place.

"courseResults",
  [
    73,
    253,
    305,
    382,
    451,
    538,
    598,
    661,
    718,
    774,
    821,
    864,
    919,
    979,
    1026,
    1074,
    1123,
    1174,
    1224,
    1285
  ],
  {
    "_74": 75,
    "_76": 77,
    "_78": 79,
    "_80": 81,
    "_82": 83,
    "_84": 21,
    "_85": 86,
    "_87": 88,
    "_89": 90,
    "_91": 92,
    "_93": 94,
    "_95": 96,
    "_97": 98,
    "_99": 100,
    "_101": 102,
    "_103": 104,
    "_105": 106,
    "_107": 108,
    "_109": 110,
    "_111": 112,
    "_113": 114,
    "_115": 116,
    "_117": 118,
    "_120": 121,
    "_122": 123,
    "_124": 125,
    "_126": 7,
    "_127": 128,
    "_129": 130,
    "_138": 139,
    "_143": 144,
    "_145": 146,
    "_162": 108,
    "_163": 164,
    "_165": 166,
    "_167": 168,
    "_56": 170,
    "_171": 172,
    "_221": 222,
    "_223": 125,
    "_224": 125,
    "_225": -5,
    "_226": 125,
    "_227": 228,
    "_230": 231,
    "_235": 21,
    "_236": 21,
    "_237": 7,
    "_238": 125,
    "_239": 7,
    "_240": 7,
    "_241": 7,
    "_242": 21,
    "_243": 7,
    "_244": 245,
    "_246": 247,
    "_252": -7
  },

The first array, courseResults is an index map telling us where in this giant array each result is. If you look at the site where we found this URL, you will notice 20 courses in the UI - it just so happens that there are 20 items in that courseResults array.

If you happen to check array[73], it's the following object and that turns out to be a schema map - saying that the field name at array[74] is equal to whatever is at array[75] and so on.

So, taking this further, the first result will have values in this giant array from 74 to 252. If you notice in our index map array above, the second result will start at 253 so that makes perfect sense.

If you look at array[253] you will find another schema index to indicate where to can find all the key value pairs for the 2nd result, which will actually start at array[254].

To summarize a bit on how this data is encoded:

Key Insight:

The file is basically a compressed CSV, but:

Putting It All Together

After deciphering how to read the data manually, it was time to do it programmatically.

With the help of ChatGPT, I wrote a couple functions that would take our original data and format it down to something much more useable.

function findCourseResultIndices(data: any[]): number[] {
  for (let i = 0; i < data.length; i++) {
    if (
      Array.isArray(data[i]) &&
      data[i].every((v: unknown) => typeof v === 'number') &&
      data[i - 1] === 'courseResults'
    ) {
      return data[i];
    }
  }
  throw new Error('Could not find courseResults array');
}

export function extractCourses(data: any[]): Record<string, any>[] {
  const schemaIndices = findCourseResultIndices(data);
  const courses: Record<string, any>[] = [];

  for (const schemaIndex of schemaIndices) {
    const schema = data[schemaIndex] as Record<string, number>;
    if (typeof schema !== 'object' || Array.isArray(schema)) continue;

    const course: Record<string, any> = {};

    for (const [rawFieldKey, valueIndex] of Object.entries(schema)) {
      const fieldKeyIndex = parseInt(rawFieldKey.replace(/^_/, ''), 10);
      const fieldName = data[fieldKeyIndex];
      const value = data[valueIndex];

      if (typeof fieldName === 'string') {
        course[fieldName] = value;
      }
    }

    courses.push(course);
  }

  return courses;
}

extractCourses courses is a function that takes an array of any types.

First, it calls out to another function, findCourseResultIndices. That function is iterating over the array, looking for another array of all numbers that is preceded by a string named courseResults and returns it.

Then we take that list of indices back and iterate over it. For every index in that list, we build a schema of key value pairs and rebuild down into a simple Record.

The result is something like this:

{
    _id: 'cxYvYpWXX66MoAG5f',
    name: 'Orchard Disc Golf Course',
    headline: 'Moderately wooded technical course, Par 54 unless hole 5 is in long., making that a par 4 hole. (course 55)',
    longDescription: '18 holes, 1 tee for each hole, 2 basket locations for each hole, rotated regularly.  ',
    holeCount: 18,
    price: '',
    status: 'Active',
    website: 'https://www.facebook.com/groups/270758746431057',
    latitude: 38.0998054149767,
    longitude: -97.87738949287784,
    locationText: 'Hutchinson, KS',
    yearEstablished: 2018,
    courseId: 20619,
    ratingCount: 755,
    ratingAverage: 4.385343483628396,
    country: 'United States',
    countryCode: 'US',
    state: 'Kansas',
    city: 'Hutchinson',
    accessType: 'everyone',
    availabilityStatus: 'available',
    availabilityType: 'yearRound',
    landType: [ 226 ],
    targetType: 'basket',
    targetTypeDescription: 'DISCatcher Pro (28 chains)',
    hasBathroom: true,
    hasDrinkingWater: false,
    propertyType: 'mixedUse',
    conditions: { _146: 195, _196: 21, _197: 342, _199: 343 },
    location: { _146: 166, _167: 345 },
    shortId: '7F1E',
    topPhoto: {
      _74: 348,
      _242: 349,
      _244: 350,
      _246: 351,
      _248: 352,
      _250: 353,
      _252: 354,
      _254: 355
    },
    admin1Name: 'Kansas',
    worldRankingsRating: 86.11626662182661,
    activeTargetTypes: [ 340 ],
    activeTeeTypes: [ 259 ],
    timezone: 'America/Chicago',
    layoutConfiguration: { _127: 360, _134: 363, _139: 374, _155: 380, _162: 383 },
    playFeeType: 'free',
    isCartFriendly: true,
    isDogFriendly: true,
    limitedAccessReason: undefined,
    isSmartLayoutEnabled: true,
    difficultyBins: [ 203 ],
    contactInfo: { _387: 388, _389: 388 },
    accessTypeDescription: '',
    availabilityTypeDescription: '',
    byob: false,
    dedicatedTargets: true,
    hasAvailabilityRestrictions: false,
    isLocationPrivate: false,
    isStrollerFriendly: false,
    otherFees: '',
    underConstruction: false,
    accessibility: 'notAccessible',
    distanceFromSelectedPlace: { _206: 397, _208: 398 },
    badgesInfo: undefined
  },

Voila! We have a well formed Course object. If you look at fields like topPhoto, it looks like the schema map field reference rabbit hole goes even deeper but that's for another post.

Summary

In this post, we have:

Plan For Next Time

Enable the user to search for a Place and Courses.

Disclaimer

The data accessed in this post is publicly available via standard browser network requests and does not require login, tokens, or circumvention of protections.

It it accessed as fair use and for educational purposes.