No public API, so imitate the browser
A restaurant operator ran their whole floor through a dominant Japanese reservation SaaS: today’s bookings, per-day covers, seat availability, all of it locked inside the vendor’s web portal. They wanted that feed programmatically, in real time, without a person logging in and reading a screen every few minutes. The platform offers no public API and no webhooks. The only feed is the private JSON API the vendor’s own frontend calls, and that frontend is a browser.
Which made the job narrower than “call an API.” The client I built has to pass as Chrome. It has to open the same TLS handshake Chrome opens, walk the login the same way a browser does, and have the same cookies sitting in the jar in the same order by the time credentials go out, because a modern anti-bot stack reads all of that before a single line of application logic runs. Get the fingerprint wrong and you never reach the login form.
The fingerprint Node can’t fake
Node’s TLS stack produces a JA3/JA4 fingerprint (d67b0948...) that no header can disguise. Chrome’s is different (00426f81...), and the difference lives in the ClientHello itself: cipher ordering, extension ordering, the HTTP/2 SETTINGS frame the connection opens with. You can send a flawless Chrome User-Agent over a Node socket and still get flagged, because the bytes on the wire disagree with the string in the header.
Every request routes through wreq-js, a Rust-backed transport that reproduces Chrome 142’s ClientHello byte ordering and its HTTP/2 SETTINGS sequence. I checked it live against a public JA3 inspector before anything shipped, so the match was observed rather than assumed.
That transport also carries the User-Agent and sec-ch-ua client hints, pinned to the same Chrome major version it impersonates. Which led to a small counterintuitive rule in the header builder: when the impersonating transport is active, the client sends no User-Agent of its own. Add one and the two can disagree, and a UA that contradicts the TLS layer is a bot signal in its own right. So the base header object comes back empty whenever wreq-js is driving, and the transport owns the identity end to end.
Signing in across two domains
Login is where the imitation gets tested. It runs five steps across two separate hosts, an identity domain and the reservation platform, and each step has to land in the right order with the right cookies already in hand.
The first request pulls the login page from the identity domain. Its response sets a session cookie and hands back a CSRF token plus four decoy input fields (dummy01 through dummy04) that exist to trip up autofill bots. A real browser leaves them blank, so the credential POST sends the CSRF token and keeps the decoys empty. From there the identity domain issues a 302 into an OAuth authorize page and then a store chooser. The target store is found by its name on that page, and the value that actually gets posted back is its data-storeno attribute, confirmed with a second CSRF POST. The OAuth callback lands on the reservation platform and sets that platform’s session cookie. Last, the dashboard HTML is fetched and scraped for an apiKeyCd token buried in inline JavaScript. Every later API call needs both the session cookie and that apiKeyCd header, so missing either one fails closed.
Carrying cookies correctly across those two hosts needed a domain-aware jar. A cookie set on the identity domain should reach its subdomains but never leak to the platform, so the jar matches on host === c.domain || host.endsWith('.' + c.domain) instead of a naive string compare.
The beacon that never fired
Here is the part that took longest to see. The login POST kept failing in a way that made no sense: right credentials, right CSRF token, right cookies, still rejected. The missing piece was a cookie called r_ad_token, set by a fraud-and-analytics beacon (an internal one the vendor calls “koruli”) that a real browser fires while the login page loads. A pure-HTTP client never runs that page’s JavaScript, so the beacon never fires, the cookie never arrives, and the login reads as automated for want of a token nobody documents.
Fabricating the cookie was tempting and wrong, since its value is server-issued and checked. The fix was to actually run the page. The login HTML gets parsed into a real DOM with linkedom, its inline scripts execute inside a small browser-shaped sandbox whose fetch, XMLHttpRequest, sendBeacon and Image.src all route through the same Chrome-fingerprinted transport, and the beacon request is then fired and awaited so its Set-Cookie lands before the credential POST goes out.
// A pure-HTTP client never runs the login page's scripts, so the koruli
// fraud beacon never fires and its r_ad_token cookie never gets set.
// Posting credentials without that cookie reads as a bot. So run the page.
async function primeFraudBeacon(loginHtml, jar, transport) {
const { document } = parseHTML(loginHtml); // linkedom: a real DOM
// Inline scripts assume a browser and will throw on the missing bits.
// We only want their side effects, so let the failures pass.
for (const el of document.querySelectorAll('script:not([src])')) {
try { runInSandbox(el.textContent, { document, jar, transport }); }
catch { /* analytics glue, not load-bearing here */ }
}
const beaconUrl = document.querySelector('input[name="r_ad_url"]')?.value;
if (!beaconUrl) return;
// Fire it AND await it: the Set-Cookie (r_ad_token) has to be in the jar
// before the credential POST, or we race the token we just triggered.
await transport.get(beaconUrl, { cookieJar: jar });
} The waiting is the whole trick. Fire the beacon and immediately POST, and you race the cookie you just triggered, so the login sees the same empty jar it would from a bot.
From bundles to a live dashboard
With login solved, the feed itself was mostly cataloguing. I crawled the platform’s public JavaScript bundles and read the call sites statically, which turned up 227 distinct endpoints. Of those, 138 read-only ones had their request-body shapes inferred straight from the bundle source, and 16 were confirmed against the live API so the paths that mattered were verified rather than inferred.
The operator sees none of that. They open a zero-build single-page dashboard: KPI cards for total, today’s and upcoming bookings plus total covers, a row of per-day occupancy bars that fill from white through amber to red as seats book up, and a searchable booking list with status filters, sorting, and a 60-second refresh. Behind it, a 30-second response cache and in-flight request de-duplication keep the upstream API touched at most twice a minute no matter how many browser tabs are hammering refresh. Login is the highest-risk action in the whole system, so the client also reuses a session for up to 20 minutes, serialized to disk and validated with one cheap read call before it is trusted, and paces its hops with 350 to 1,400ms of jitter. Navigation requests carry Sec-Fetch-Mode: navigate and XHRs carry cors, the way Chrome sends them.
Where it breaks, and how fast you’d know
I architected and built this solo. The standing risk is platform-side. Any of the five login hops can rename a CSRF field, retarget a redirect, or reshape its HTML, and the flow breaks the moment one does. So each step is documented with the exact attributes it parses and the redirect chain it expects, which turns a future break into a few-minute diagnosis rather than a spelunk through five hops. The detection risks I cannot fully close, datacenter IP reputation and the absence of third-party analytics cookies, are written up separately with the countermeasures I would reach for next.
The client ships as a library. import { RestaurantBoardClient } and you can embed it in a larger service, drive it from a CLI, or run it as the monitored HTTP server behind the dashboard, all from one codebase.
Outcomes
- 5-step OAuth
- JA3/JA4 matched
- 227 endpoints
- Zero vendor API
- Production-ready
- Low-profile by design