<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hadriel </title>
    <description>The latest articles on DEV Community by Hadriel  (@hadriel33).</description>
    <link>https://dev.to/hadriel33</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3935230%2Fe16f4bdc-d60e-49ab-8a3a-71e586da23a0.png</url>
      <title>DEV Community: Hadriel </title>
      <link>https://dev.to/hadriel33</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hadriel33"/>
    <language>en</language>
    <item>
      <title>How I reverse-engineered Playtomic's mobile payment API to build a padel court booking bot</title>
      <dc:creator>Hadriel </dc:creator>
      <pubDate>Sat, 16 May 2026 17:37:56 +0000</pubDate>
      <link>https://dev.to/hadriel33/how-i-reverse-engineered-playtomics-mobile-payment-api-to-build-a-padel-court-booking-bot-5ehb</link>
      <guid>https://dev.to/hadriel33/how-i-reverse-engineered-playtomics-mobile-payment-api-to-build-a-padel-court-booking-bot-5ehb</guid>
      <description>&lt;p&gt;Hey Dev.to community 👋&lt;/p&gt;

&lt;p&gt;I'm Hadriel, a 25yo solo founder building &lt;a href="https://padelsnipe.com" rel="noopener noreferrer"&gt;Padel Snipe&lt;/a&gt; from Bordeaux, France. Today I want to share how I reverse-engineered Playtomic's mobile payment API to build a padel court booking automation.&lt;/p&gt;

&lt;p&gt;If you're into reverse engineering, anti-bot strategies, or just curious how booking automation works in practice, this should be interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Padel (a racket sport huge in Spain and growing fast in France) has a booking problem. At popular clubs like PADEL 15 in Bordeaux, courts get fully booked within 30 seconds of opening on Playtomic, the dominant European booking platform.&lt;/p&gt;

&lt;p&gt;I wanted to build a bot that monitors slot openings and books at the millisecond a court becomes available. Sounds simple. It wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first wall: Playtomic uses Next.js Server Actions
&lt;/h2&gt;

&lt;p&gt;My first approach was the classic one: intercept the booking calls in the browser using Chrome DevTools, replay them via HTTP.&lt;/p&gt;

&lt;p&gt;It didn't work.&lt;/p&gt;

&lt;p&gt;Playtomic's web app uses Next.js 13+ Server Actions for the payment flow. The actual payment logic runs on their server, not in the browser. The browser just sends a serialized action call, and the server responds with a redirect or error.&lt;/p&gt;

&lt;p&gt;You can't intercept what doesn't exist client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: capturing the mobile app
&lt;/h2&gt;

&lt;p&gt;I switched strategy: capture the mobile app's API calls instead. The mobile app is a thin client that calls REST endpoints directly.&lt;/p&gt;

&lt;p&gt;Setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iPhone with Playtomic installed&lt;/li&gt;
&lt;li&gt;Windows PC running mitmproxy&lt;/li&gt;
&lt;li&gt;iPhone proxied through the PC's IP&lt;/li&gt;
&lt;li&gt;mitmproxy CA cert installed on iPhone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I made a real booking through the app, captured everything, and analyzed the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-step payment flow
&lt;/h2&gt;

&lt;p&gt;After analyzing the captured traffic, the payment flow turned out to be exactly 4 sequential calls:&lt;/p&gt;

&lt;p&gt;​&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Step 1: Create payment intent&lt;/span&gt;
&lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;payment_intents&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;match_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;uuid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;  &lt;span class="c1"&gt;// cents&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// → returns payment_intent_id&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: Select payment method (prefer CREDIT_CARD)&lt;/span&gt;
&lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;payment_intents&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/payment_metho&lt;/span&gt;&lt;span class="err"&gt;d
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;payment_method_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CREDIT_CARD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 3: Update match registrations&lt;/span&gt;
&lt;span class="nx"&gt;PATCH&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;match_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/registration&lt;/span&gt;&lt;span class="err"&gt;s
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;registrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pay_now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pay_now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pay_now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pay_now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 4: Confirm (empty body!)&lt;/span&gt;
&lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;payment_intents&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/confir&lt;/span&gt;&lt;span class="err"&gt;m
&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty body on step 4 was the surprise. I lost an hour debugging "why does my POST return 400" before realizing the API expects exactly &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting when clubs open their booking windows
&lt;/h2&gt;

&lt;p&gt;Once I could book, I needed to know &lt;strong&gt;when&lt;/strong&gt; to book. Each club has its own opening rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some open J-7 at 8am (the "classic" pattern)&lt;/li&gt;
&lt;li&gt;Some open J-5 at the exact mirror hour of the slot (PADEL 15 does this)&lt;/li&gt;
&lt;li&gt;Some have weird custom rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built a 3-phase detection system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Manual user input&lt;/strong&gt; — when a user adds a club, they can specify the rule if they know it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crowdsourced votes&lt;/strong&gt; — users see the proposed rule and can confirm or dispute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary dichotomic scan&lt;/strong&gt; — a worker probes the API to find the exact opening time, narrowing to 15 minutes precision&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dichotomic scan was the most fun to write. Given a target date, the worker queries the availability endpoint with different &lt;code&gt;days_ahead&lt;/code&gt; values, watches when the slot first appears, then does a binary search on the time-of-day to find the exact opening moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worker architecture
&lt;/h2&gt;

&lt;p&gt;I used &lt;a href="https://docs.bullmq.io/" rel="noopener noreferrer"&gt;BullMQ&lt;/a&gt; on Railway with &lt;a href="https://upstash.com" rel="noopener noreferrer"&gt;Upstash Redis&lt;/a&gt; (Ireland region for low latency to Playtomic's EU servers).&lt;/p&gt;

&lt;p&gt;Key learnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't hold concurrency slots while waiting&lt;/strong&gt;: a snipe waiting for an opening shouldn't block 1 of 10 worker slots. Use delayed re-enqueue (&lt;code&gt;+5min&lt;/code&gt;) instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitize job IDs&lt;/strong&gt;: BullMQ silently fails when job IDs contain colons. I lost 3 hours to this. Added &lt;code&gt;sanitizeJobId()&lt;/code&gt; helper everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit handler&lt;/strong&gt;: 429 responses need exponential backoff separate from retry count, otherwise you blow through retries on temporary rate limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The first successful production snipe
&lt;/h2&gt;

&lt;p&gt;After 6 months of building, my first production snipe succeeded last Tuesday at 17:31:20 UTC. PADEL 15 Bordeaux, Sunday 17:30 slot. Reaction time: &lt;strong&gt;18,702 milliseconds&lt;/strong&gt; after slot opening.&lt;/p&gt;

&lt;p&gt;The reservation held until I cancelled it myself 4 days later (couldn't find partners 😅).&lt;/p&gt;

&lt;h2&gt;
  
  
  The full stack
&lt;/h2&gt;

&lt;p&gt;For the curious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo:&lt;/strong&gt; Turborepo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web app:&lt;/strong&gt; Next.js 15, TypeScript, Tailwind, shadcn/ui&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker:&lt;/strong&gt; BullMQ on Railway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; Supabase (Postgres + Auth + Storage)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis:&lt;/strong&gt; Upstash (Ireland)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments:&lt;/strong&gt; Stripe (live mode)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emails:&lt;/strong&gt; Resend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notifications:&lt;/strong&gt; Telegram bot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse engineering:&lt;/strong&gt; mitmproxy + iPhone&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'm working on next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anti-detection layer&lt;/strong&gt;: UA rotation, jitter, sticky proxies per userId (waiting for ~30 simultaneous users before activating)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern learner&lt;/strong&gt;: using captured observations to predict opening times per club within 15-minute windows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B2B2C&lt;/strong&gt;: clubs can co-brand a landing page and earn commission on referred users&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you play padel in France, UK, or Spain, you can try Padel Snipe for free at &lt;a href="https://padelsnipe.com" rel="noopener noreferrer"&gt;padelsnipe.com&lt;/a&gt;. FREE plan includes real-time alerts, PRO (14€/month) adds auto-booking, ELITE (29€/month) adds priority booking.&lt;/p&gt;

&lt;p&gt;I'm also documenting the full build journey on &lt;a href="https://buildingpadelsnipe.substack.com" rel="noopener noreferrer"&gt;Building Padel Snipe&lt;/a&gt; — weekly newsletter with technical decisions, real data drops, and honest growth metrics.&lt;/p&gt;




&lt;p&gt;Happy to answer questions about any part of the stack or the reverse engineering process. Especially curious if anyone here has shipped similar millisecond-precision automation against booking platforms — would love to swap notes on anti-bot strategies.&lt;/p&gt;

&lt;p&gt;Cheers from Bordeaux 🇫🇷&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
