I sent BookMyShow requests from an Oracle Cloud VPS and got 403. I changed the User-Agent, added Chrome headers, kept cookies, and forced HTTP/1.1. BookMyShow rejected those requests too.
On the same VPS, node-tls-client with the safari_ios_18_0 profile returned 200. I kept the datacenter IP and dropped the proxy shopping.
That experiment shaped SeatSniper, my self-hosted ticket watcher. Users submit a BookMyShow link through a Discord slash command. I store their watches in SQLite, share upstream requests across users, and send notifications when matching dates or cinemas appear. I run the bot with Bun 1.2, TypeScript, and Discord.js v14.
SeatSniper observes listings. I do not automate purchases, hold seats, or fill carts. A notification means I found a matching listing during a poll, not that I reserved a ticket.
Diagnose the handshake before renting an IP
A browser sends more than HTTP headers. During TLS negotiation, the client proposes cipher suites, extensions, supported groups, and application protocols through its ClientHello. Browser families produce recognizable combinations. A server can also inspect HTTP/2 settings after the handshake.
Security teams use JA3 and JA4 to summarize aspects of those fingerprints. Neither fingerprint represents a complete browser identity, and neither tells an operator the entire reason for a Cloudflare decision. IP reputation, geography, request volume, and challenges can still affect access.
For this deployment, I could isolate the client fingerprint as a practical blocker: I changed the client while keeping the VPS fixed. An apparent datacenter-IP ban can hide a TLS or HTTP/2 fingerprint problem. Buying a residential proxy would have changed another variable before I understood the first one.
I recorded these results on July 27, 2026 in the repository's access findings:
| Environment | Client | Result |
|---|---|---|
| Oracle VPS, datacenter IP | Bare curl | 403 |
| Same VPS | Curl with Chrome User-Agent and headers | 403 |
| Same VPS | Bun fetch()
|
403 |
| Same VPS |
node-tls-client, safari_ios_18_0
|
200 |
| Same VPS | Python curl_cffi, Safari or Edge impersonation |
200 |
| Residential connection | Curl on Raspberry Pi, OpenSSL 1.1.1w | 403 |
The Python result matters: the language did not determine access. Python curl impersonation worked; a conventional client could fail on a residential connection.
In a ten-request Safari-profile run on Oracle, I recorded ten 200 responses, with 37 ms minimum, 48 ms median, and 116 ms maximum latency. Those numbers describe that experiment, not a latency guarantee or a benchmark against every proxy provider.
Keep the browser profile coherent
I use node-tls-client, which wraps the Go tls-client implementation. Through that stack, I select a browser-shaped TLS ClientHello and HTTP/2 configuration without launching a browser process.
In src/bms.ts, I initialize the native layer once, then create a reusable session. I default to safari_ios_18_0 and expose BMS_TLS_PROFILE for supported Safari or Firefox profile overrides.
I send no custom headers on BookMyShow requests. In my measurements, adding a full Chrome header set to a working residential Bun request changed the response from 200 to 403. A Chrome User-Agent does not change the TLS handshake. I treat the mismatch as a reason to keep the profile and request shape together, rather than stacking headers from unrelated examples.
I cannot promise that Safari impersonation will keep working. Cloudflare can change its scoring, BookMyShow can add a JavaScript challenge, and operators outside India can encounter geographic restrictions. SeatSniper reports blocked requests instead of treating them as an absence of tickets. Respect the site's terms and restrictions; this setup provides no authorization to evade access controls or increase traffic.
Store user intent once, share upstream work per cycle
Fifty users can watch the same film. If I fetch the same listing fifty times, I increase load without learning anything new.
I separate two kinds of deduplication:
-
Persistent watch deduplication: SQLite rejects a duplicate watch through
UNIQUE (user_id, event_code, date). -
Request coalescing: an in-memory map shares one promise for a
(city, eventCode)lookup during a poll cycle.
SQLite stores the watch queue and notification history. I do not store the network cache in SQLite. The per-cycle promise maps live in src/bms.ts, and I reset them through beginCycle().
sequenceDiagram
actor User
participant Bot as Discord bot
participant DB as SQLite watch queue
participant Cache as Per-cycle promise map
participant BMS as BookMyShow via Safari TLS
participant Discord as Discord delivery
User->>Bot: /watch link date filters
Bot->>BMS: Validate movie through Safari profile
BMS-->>Bot: Movie title and listing data
Bot->>DB: Insert watch with unique user/event/date
DB-->>Bot: Saved ID or duplicate
Bot-->>User: Watch confirmation
loop Each poll cycle
Bot->>DB: Read active watches
Bot->>Cache: Clear previous cycle
Bot->>Cache: Lookup city and event
alt First watch for this movie
Cache->>BMS: Fetch bookable dates
BMS-->>Cache: Dates, venues, format chips
else Another watch for same movie
Cache-->>Bot: Reuse stored promise
end
Cache-->>Bot: Shared availability result
Bot->>Bot: Apply watch-specific filters
opt Matching new availability
Bot->>Discord: Send direct message
Discord-->>User: Ticket notification
Discord-->>Bot: Delivery outcome
Bot->>DB: Record seen state or retire dated watch
end
end
I include the city in the cache key because BookMyShow returns different cinemas and showtimes for Mumbai, Delhi, and Pune. I omit the slug because I measured that BookMyShow uses the event code as the authoritative movie identifier.
For fifty watches on the same movie and city, I make one shared bookable-date request per cycle. That does not mean the entire cycle must cost one request. Once a date opens, I may need showtimes for it. I cache those requests under (city, eventCode, date). Format-specific child events can add requests too.
A coalesced loop with rate jitter
This reduced excerpt shows the repository's polling structure. checkWatch() uses bookableDatesCached() and, when needed, fetchShowtimesCached(). I omit command handling and notification formatting so the cache boundary remains visible.
import {
beginCycle,
coalescedCount,
} from "./bms.ts";
import { allWatches } from "./db.ts";
import { staggerBounds, staggerDelayMs } from "./stagger.ts";
const bounds = staggerBounds(); // Defaults: 2000 to 5000 ms
async function poll() {
const watches = allWatches();
beginCycle();
for (const watch of watches) {
try {
// Existing application functions from src/index.ts:
if (!(await expireStale(watch))) {
await checkWatch(watch);
}
} catch (error) {
console.error(`[watch ${watch.id}] poll failed:`, error);
}
await Bun.sleep(staggerDelayMs(bounds));
}
console.log(
`[poll] ${watches.length} watches; ` +
`${coalescedCount()} requests saved`,
);
}
Under the cache, I insert the promise before returning it:
const key = `${target.city}|${target.eventCode}`;
const hit = cycle.get(key);
if (hit) return hit;
const pending = fetchBookableDates(target);
cycle.set(key, pending);
return pending;
By caching the promise, I share an in-flight request as well as a completed answer. I keep that answer within the cycle, then fetch fresh data next time. A rejected promise also remains shared within that cycle, so I avoid fifty retries against the same blocked endpoint.
I configure the delay through STAGGER_MS_MIN and STAGGER_MS_MAX. The defaults sample a uniform delay from 2,000 ms up to 5,000 ms between watches. Jitter spreads activity; it does not grant permission to poll faster or solve a challenge.
I also pay for that pacing in notification latency. Fifty watches incur about 175 seconds of stagger delay on average, before network and delivery time. The repository schedules polls every 600 seconds by default. Users should read that as periodic detection, not an instantaneous guarantee. At larger watch counts, an operator must account for cycle duration and the current interval scheduler's potential for overlapping cycles.
Read the date on each show
An HTTP 200 and a page full of showtimes can still produce the wrong answer.
BookMyShow can return the nearest bookable date when I request a date that has not opened. The URL and page-level current-date field can echo my requested date while the show objects describe a different day.
I check the showDateCode inside each show's additionalData:
const matching = shows.filter(
(show) => show.showDateCode === requestedDate,
);
In the recorded test, I requested July 30 and parsed 34 shows. None carried showDateCode: "20260730"; they belonged to July 27. Counting shows would have produced a false notification.
For the shared movie-level check, I fetch the buytickets HTML using the probe date 20991231. BookMyShow includes the requested date in its date list, so I remove that sentinel from the parsed dateCode values. I verified the remaining date sets against individual date requests for three films in the recorded investigation. After a positive date-level result, I inspect actual showtimes before sending a dated-watch alert.
I validate the page before accepting an empty result. The current provider checks for window.__INITIAL_STATE__ and a movie title. A challenge page without the app marker raises an error. A recognizable movie page with no showtimesSections can represent a legitimate empty listing. That distinction matters when upstream HTML changes: I need an error for an unknown page, not a fabricated “tickets have not opened” answer.
Keep the filter engine tied to show data
A watcher becomes noisy if a user wants IMAX and I notify them about any screening.
Users can submit filters through /watch:
/watch link:<BookMyShow URL> date:any
format:IMAX,4DX theatre:PVR,INOX
days:fri,sat after:18:00 before:23:00
I normalize format names and compare them without case or whitespace sensitivity. IMAX matches IMAX 3D; SCREENX matches SCREEN X. BookMyShow also lists some formats under separate child event codes. I parse the format-selector chips and inspect matching child events, rather than assuming the parent event contains every screen type.
For theatres, I match a case-insensitive substring of the venue name or venue code. A user can choose PVR or paste a code such as IMOB. I apply theatre restrictions to new-cinema alerts using the venue data I already fetched.
Time windows use Indian Standard Time, UTC+05:30, regardless of the host's timezone. I convert BookMyShow's IST show datetime into an epoch and compare the show's IST wall-clock minutes against the user's bounds. I treat after:18:00 as inclusive and before:23:00 as exclusive. I reject a window that wraps through midnight; users cannot express after:23:00 before:01:00 as one watch window.
Subscriptions need an extra distinction. A date:any watch can report a new date or a new cinema. I apply format, weekday, and showtime checks to date alerts. I do not claim that a new-cinema alert satisfies those checks: I detect fresh cinemas by venue code, and apply the theatre filter there. The command documentation spells out that boundary.
Persist notification history, then handle delivery failure
I use Bun's built-in bun:sqlite API in src/db.ts. I keep watches, seen_dates, and seen_venues in seatsniper.db, or the path configured through DB_PATH.
seatsniper.db
+ watches
| user, city, event, date, filters, failure state
+ seen_dates
| watch_id + date_code
+ seen_venues
watch_id + venue_code
Per-cycle memory
+ city | event -> Promise<bookable dates>
+ city | event | date -> Promise<showtimes>
I enable WAL mode and foreign keys. WAL mode can create seatsniper.db-wal and seatsniper.db-shm, so “single-file database” describes deployment simplicity, not a promise that a running SQLite process uses only one filesystem entry. For live backups, use SQLite-aware backup tooling rather than copying the main file while ignoring pending WAL writes.
I seed a subscription's seen-state from the listings available when the user creates it. I then notify them about later additions. For a dated watch, I send one alert and retire the watch after successful delivery.
I attempt the DM first. If Discord rejects it, I try the originating text channel and tell the user that I used the fallback. If both attempts fail, I retain the dated watch or leave subscription additions unrecorded, then retry on a later poll.
I record seen-state after delivery. That ordering reduces lost notifications, but I cannot make SQLite writes and Discord delivery one atomic transaction. A crash after Discord accepts a message and before I commit seen-state can cause a duplicate on restart. Operators should expect that failure window rather than promise exactly-once messaging.
For upstream checks, I track consecutive failures and send a warning at the third failure. I reset the counter after a successful check. That lets a user distinguish an unavailable movie date from a watcher that cannot read BookMyShow.
Run it without adding infrastructure
I keep the manual path small:
git clone https://github.com/Ishannaik/seatsniper.git
cd seatsniper
bun install
cp .env.example .env
Add your Discord bot token as DISCORD_TOKEN and application ID as DISCORD_CLIENT_ID in .env. Keep those values out of Git. Then register commands and start the bot:
bun run commands
bun run start
The repository also provides Docker instructions and a Linux/macOS installer. I would inspect the installer before running a downloaded shell script. Windows users can use manual setup or Docker.
For monitoring, I can configure UPTIME_KUMA_PUSH_URL. I send its heartbeat after a completed poll, including idle cycles. A running container alone does not prove that the bot logged into Discord or completed an upstream check.
I chose this design for a small self-hosted service: one Bun process, one local SQLite database, one native TLS client, and Discord delivery. I still have to preserve the database volume, monitor failed polls, and react when BookMyShow changes its HTML or access policy.
The two decisions I would repeat are the controlled client experiment and the per-cycle cache. By keeping the VPS fixed, I found a working transport without paying for residential egress. By sharing each movie lookup, I let fifty users observe the same release without sending fifty copies of the same request.
Source and deployment instructions: Ishannaik/seatsniper. The measured access findings include the response matrix, date-substitution tests, and limits behind the claims above.
Top comments (0)