Files

6.8 KiB

MLH Global Hack Week — partner promo claim (Cursor Pro etc.)

Full working path from event check-in to promo code. Claim-link discovery, phone API, relogin, and cookie handling all verified live; final SMS confirm ran on the user's real SIM (code relayed in chat) — everything upstream of it is proven.

  • MLH sends a "Get free partner offers at Global Hack Week" email (~day 2 of event) to the account's registered mailbox. Search IMAP: SUBJECT "partner offers" or body keywords redeem|promo_codes|claim.
  • Links are tracker-wrapped: https://transactional.mlh.io/ls/click?upn=.... Resolve each by fetching with redirects followed; some land pages carry the real target in a meta-refresh url=.
  • PITFALL: dedupe tracker links by FULL string, never by prefix — distinct destinations share the first ~80 chars of the upn param and prefix-dedup collapses them, hiding the claim link.
  • The claim URL looks like: https://www.mlh.com/events/<event-slug>/partners/<partner-slug>/promo_codes/redeem?token=<JWT>. The token is REQUIRED — the same path without ?token= returns an Errors/400 page (validLink=false).

2. Read claim state (Inertia props)

GET the claim URL with account cookies → parse <script type="application/json"> → props:

  • validLink (bool), isSignedIn, userMismatch (link belongs to another account)
  • unmetClaimRequirements — the live TODO list. Typical set: verified_phone, github_oauth, github_account_age (GitHub 30+ days old — fresh farmed accounts fail this), checked_in
  • redeemUrl — POST target, e.g. /events/<slug>/partners/<partner>/promo_codes
  • token — echo it back: claim = POST redeemUrl {token: <JWT>} with CSRF/XSRF headers. Response/props then carry promoCode.
  • redemptionLimitExceeded — codes are limited; claim ASAP once unmet list is empty.
  • The pool can be EMPTY even with every requirement met: claim returns "No unclaimed codes in pool" (confirmed by multiple independent users during GHW; MLH restocks unpredictably). Do not treat it as a flow bug — schedule retries (watcher cron) until event endsAt and claim the moment codes reappear.

Recon trick for any mlh.com page: the Vite loader stub (/vite/assets/application-*.js) + inertia bundle map component names to chunk files (e.g. Redeem-*.js, claimRequirements-*.js). Fetch the chunks to read exact prop names, endpoints, and data-testid values without a browser.

3. Phone verification (the hard gate)

API (needs Turnstile token, action phone_verification, sitekey 0x4AAAAAAARi-8xgLgfKOXIb, solved via 2captcha). Add accepts channel sms or whatsapp. Before any wave, check every captcha key's balance (POST res.php {key, action:getbalance}) and pin the funded one — scripts that hardcode cfg["keys"][0] fail with ERROR_ZERO_BALANCE, solve() returns None, and the server answers a misleading 422/400 on the EMPTY turnstile token instead of naming the captcha.

  1. POST /api/phone_numbers {"phone_number":{"number":"<E164>"}, "token":<ts>, "cf-turnstile-response":<ts>} → 201 with {"id":...}; MLH auto-sends SMS.
  2. 422 "verify or remove existing" → DELETE /api/phone_numbers/<id> (ids listed in /account/settings props phoneNumbers), then re-add.
  3. Resend: POST /api/phone_numbers/<id>/reverify → 200 {"message":"Verification code sent."}. Guessed paths like /verification_code or /confirm return Inertia Errors/404 pages — read exact paths from the useAddPhone-*.js chunk instead of inventing them.
  4. Confirm: POST /api/phone_numbers/<id>/confirm_verification {"code":"<from SMS>"} — this payload needs NO turnstile token. The code lives ~15 minutes; an expired/wrong code returns 422 {"error":"Something went wrong while verifying your phone..."} → reverify and get a fresh code, do not retry the old one. Verified state shows in /account/settings props phoneNumbers (claimed:true).

CRITICAL: Twilio/MLH delivers ZERO SMS to rental/virtual numbers. Tested 8 numbers across 7 countries on hero-sms (MA, HK, UK, ID, PH, KE, KZ — including physical-heavy pools, 25k+ "physicalCount") — every add returned 201 and every reverify 200, but no SMS ever arrived. Do NOT burn budget iterating countries; the block is categorical. The only path that works is a REAL SIM (ask the user to read one code; script modes add/reverify/confirm, cf. tmp/mlh_phone_v6.py).

4. hero-sms rental API (for other tasks, not MLH phone)

  • Working base: https://hero-sms.com/stubs/handler_api.php (sms-activate protocol: getNumber/getStatus/setStatus). The partners-api.hero-sms.com host 403s these endpoints.
  • getBalance → ACCESS_BALANCE:<usd>; getPrices → per country {count, price, physicalCount} — sort candidates by physicalCount, not count.
  • No SMS within the window → setStatus status=8 (ban) refunds the rental.
  • New accounts start at $0 balance — registration gives no free credits.

_mlh_core_session expires within ~12-14h — any multi-day flow gets 401 {"message":"You are signed out..."}. Re-auth is fully automatable (script: tmp/mlh_relogin.py):

  1. GET https://www.mlh.com/signin → regex "version":"([a-f0-9]+)" (Inertia version) and <meta name="csrf-token" content="...">.
  2. Solve Turnstile with action authentication (same sitekey as phone).
  3. POST https://www.mlh.com/signin JSON {"email":..., "return_to":"", "cf-turnstile-response":<ts>} with headers X-Inertia: true, X-Inertia-Version: <ver>, X-CSRF-Token: <csrf>, Content-Type: application/json → 200, lands on /signin/<id>/otp, magic-link email fires.
  4. IMAP (gmail +alias accounts all land in the base inbox): watermark the inbox BEFORE the POST, then poll NEW ids only, match the account tag in the TO header, extract Sign in\s*\(\s*(https://transactional\.mlh\.io/[^)\s]+)\s*\) from text/plain (fallback: href="https://transactional.mlh.io/..." in text/html). Link arrives in <10s.
  5. GET the link with a CookieJar → lands on /account/profile, isSignedIn: true → dump jar as k=v; k=v string into the cookies file the phone/claim scripts read.

Keep TWO cookie stores: Netscape jar file and a raw k=v; k=v string. The jar can silently hold a stale signed-out session (pages render isSignedIn:false) while the string file is live. For plain fetches, string cookies in a Cookie: header via http.client is the reliable path; re-validate with a cheap authenticated GET (/account/settings → check isSignedIn) before long flows. Note urllib.request.urlopen needs FULL URLs — passing a path raises unknown url type.

Event timing

GHW events run ~6 days (status: in_progress, endsAt in event props). Promo emails land mid-event; claim links are per-account unique and expire with the event.