Tom Planche

Plane made by Nora
← writing

I reverse-engineered my volleyball club booking app

A friend and I were fed up with the booking system of the MonClub app our volleyball club uses. A slot only opens for booking exactly six days (144 hours) in advance, at a precise time. Spots are limited and fill up almost instantly, so you have to rush onto the app the very moment booking opens.

In practice that meant being glued to our phones at an exact minute, six days ahead. And we would regularly miss out anyway, because we were busy, away from our phones, or simply forgot. So I did the reasonable thing: I reverse-engineered the app's booking flow and built my own tool to talk to it directly, a terminal CLI and a Discord bot that book, cancel and manage sessions without ever opening the app. No more being glued to a phone at a precise minute.

This is how it works.

Listening in on the app

MonClub does not expose a public API, so there was nothing to read. I had to watch the app talk to its server. I intercepted the app's HTTPS traffic using Proxyman on macOS, with an iPhone as the client. Proxyman acts as a man-in-the-middle proxy: once you trust its CA certificate on the device, it can decrypt the TLS traffic and show you every request and response in clear text.

From there it was a matter of using the app like a normal human (opening the "Sessions" tab, tapping "Book", tapping "Cancel") and watching which requests fired. Every interesting capture got saved so I could study it offline.

The IDs that glue everything together

The first thing that became obvious is that every request is built out of a handful of opaque MongoDB ObjectIds (24-character hex strings). There are four that matter:

  • userId identifies the logged-in user. It comes back in the auth response.
  • customId identifies the club (the tenant). It shows up in every request, sometimes in the body and sometimes as a query parameter. It is stable and never changes for a given club, so I can treat it as a constant.
  • sessionId identifies a specific session slot. It is the _id of a session in the listing response.
  • bookingId identifies a booking record. It is the _id of a booking and is required to cancel one.

customId aside, the others are fetched at runtime: log in, list the sessions, list the bookings.

Authenticating like the real app

The traffic revealed a two-step authentication flow.

Step 1 is POST /users/custom/authenticate/email/v2, which sends only the email address. The server most likely uses this to check whether the account exists and what login method it should use.

Step 2 is POST /users/custom/authenticate/v2, which sends the credentials plus some device metadata. It returns a raw JWT (no Bearer prefix) and the userId.

The device metadata in the body turned out to be completely cosmetic. The server accepts arbitrary values, so the bot just hardcodes a plausible-looking phone:

{
  "os":      "Android 14",
  "model":   "Phone (2)",
  "brand":   "Nothing",
  "version": "3.6.0"
}

After that, the JWT goes out as Authorization: <token> on every subsequent request (again, no Bearer prefix). The nice surprise is that the token's expiry is about a year, so the bot can simply re-authenticate on every run and never bother with token refresh logic.

Finding the slots

Listing sessions is POST /nearfilters/favorite/myclub. It returns all upcoming sessions for the user's clubs, and the tagName: "myclub" filter scopes the results to clubs the user is actually a member of. I found it by capturing the traffic while opening the "Sessions" tab. Each session in the response carries its own _id, which is the sessionId I need to book it.

To see what is already booked, there is:

GET /bookings/user/:userId?category=ondemand&temporality=fromToday

This returns the user's upcoming bookings. Each entry has a nested session array with the session details, and a top-level _id which is exactly the bookingId required for cancellation. Swapping the query to temporality=beforeToday returns past bookings instead. Handy detail: the session object also includes yesParticipants (an array of user ID strings) and totalQuantityFree (the capacity as an integer), which is how the app shows the "X / Y spots taken" count.

Booking (and un-booking)

This is the part I like the most. Booking and cancelling are the same endpoint: POST /sessions/book/licenseeFromClub. What decides between the two is the isPresent field inside the participant object:

  • "yes" creates a booking.
  • "no" cancels one. In that case the participant object also needs the bookingId.
{
  "isPresent": "yes"   // "no" to cancel (also needs bookingId)
}

So the whole "grab my slot" action boils down to: authenticate, find the right sessionId, and POST it with isPresent: "yes".

There is one subtlety I only spotted after a booking silently failed: a 200 OK on this endpoint does not mean the booking went through. The real outcome lives in the response body, not the HTTP status. A confirmed booking comes back either as a record with an _id or with status: "success". A soft rejection returns a different status plus a message, for example status: "noCredits" when the account has hit its reservation limit. So the bot treats only an explicit non-success status as a failure, and it does not retry those, because retrying a "you have no credits left" answer never helps.

The timing problem, solved by a status code

My first worry was timing: the app insists a slot only opens 144 hours before the session, so I assumed I would have to fire a perfectly-timed request at the exact second. The reverse-engineering gave me a calmer answer.

If you POST a booking for a session the server is not ready to accept yet, it replies with 409 Conflict. That is not really an error in my case; it just means "not open yet".

409 Conflict on the booking endpoint means the slot is not yet open. This is expected for sessions that open at a specific time, which is why the bot retries on 409.

That single status code turns a stressful timing game into a boring loop. Instead of firing one perfectly-timed request at the exact second, my booking flow just submits, and on a 409 it retries every few seconds until a deadline I set. The first time the server stops returning 409, the booking has gone through. I never have to trust that my clock and the server's clock agree down to the millisecond; the server itself tells me when I am allowed in.

The best part: there is no real time limit

Here is the kicker I did not expect. That "six days in advance" rule? It is only enforced in the app itself. The server does not check it at all. The 144-hour window is a client-side restriction: the app refuses to show you the button early, but the booking endpoint behind it has no such guard.

So once I was talking to the API directly, the whole premise of the stress disappeared. I am not limited to the next six days anymore. I can book any session I want, whenever I want, with no time limit at all. The bot does not just win the race to the slot; it ignores the starting line entirely.

What I actually built

None of this is a screen-tapping macro. It is a small Rust program that speaks the API directly, and it comes in two flavours.

The first is an interactive terminal CLI. It logs in, then drops me into a menu: list every upcoming session, book one (with the 409-retry loop above), view or cancel my existing bookings, browse past sessions, or even compare the attendee lists of two sessions to see who else is coming. When more than one account is configured it also asks who to book for, so a single run can grab the same slot for several people. There is also a prebook command for the rare genuinely time-gated slot: I pick a session and a target time, and it sleeps until then before running the same retry loop.

The second is a Discord bot with the same powers, exposed as slash commands (/list, /book, /cancel, /prebook, /bookings, and a per-booking /booking detail view), so a friend and I can book straight from our group chat. If a single-target /book comes back with a 409, a background task keeps retrying and sends a follow-up message once the slot is confirmed.

Booking for the whole group

The feature that turned this from "my tool" into "our tool" is multi-user booking. Each of us has our own MonClub account, so the bot can hold several sets of credentials at once: the primary account comes from the environment, and extra people live in a small gitignored users.json that maps each account to a Discord user. Once that is set up, /book and /cancel take an optional list of people (@tom @nils, raw Discord ids, labels, or @everyone for every configured account), and the bot books or cancels for all of them in one command. Cancelling is per-person: for each target it looks up that account's own booking for the session and cancels it.

Booking a group brought a problem a single booking never had: partial failure. If I book four people and the third one is out of credits, I do not want to end up with two friends booked and two not, especially for a slot where the remaining spots may already be gone. So group bookings are atomic. The bot books each account in turn, and the moment one fails, for any reason (no credits, an error, or the slot simply not being open yet), it rolls back by cancelling every booking it already made in that batch. Either the whole group gets in, or nobody does and the state is left exactly as it was. It is the same all-or-nothing guarantee a database transaction gives you, built out of two API calls and a bit of bookkeeping.

Was it worth it?

Honestly, yes. The actual API surface is tiny: two auth calls, one listing call, one bookings call, a session-detail call, and one endpoint that both books and cancels. Most of the work was the watching and the figuring-out, not the code. And now, instead of setting an alarm for a random minute six days from now, I book from my terminal or a Discord message whenever it suits me, and we get to play volleyball.

If you want the gory details, the full write-up and the raw traffic captures live in the monclub-bot repository.