A small, self-hosted Viagogo ticket monitor with zone-aware filtering and rich Discord alerts.
Ticket Watch polls a Viagogo event at a configurable interval, keeps the latest result in PostgreSQL, and sends a Discord embed when the tracked price changes. A lightweight web dashboard manages monitors, while Playwright discovers Viagogo's human-readable ticket zones and converts them into the internal filter IDs required by listing requests.
Important
Ticket Watch is an experimental personal project. It uses Viagogo's website and internal web requests rather than a stable public buyer API. It is not affiliated with, endorsed by, or sponsored by Viagogo. Review the limitations and responsible-use notes before running it.
- What it does
- Screenshots
- Architecture
- How monitoring works
- Technology
- Requirements
- Quick start
- Viagogo session setup
- Running the application
- Using the dashboard
- Zone discovery
- Discord notifications
- API reference
- Project structure
- Development and verification
- Operations and deployment
- Troubleshooting
- Limitations and responsible use
- Creates named monitors from Viagogo event URLs.
- Checks every 30 seconds, 1 minute, 5 minutes, 10 minutes, or any API-provided interval of at least 10 seconds.
- Tracks either all tickets or a selected set of discovered zones.
- Supports two Viagogo sort strategies:
NEWPRICE— the lowest-priced result.RECOMMENDED— Viagogo's recommended result.
- Runs listing checks and zone discovery asynchronously through Symfony Messenger.
- Persists monitor state, scheduling data, filters, prices, and failures in PostgreSQL.
- Sends rich Discord embeds with:
- A clickable event title.
- Alert copy that distinguishes lowest-price and recommended monitors.
- Old and new prices.
- Section, row, seat, availability, ticket type, and deal score.
- A direct link to the exact listing.
- Viagogo branding and the available venue/seat-view image.
- Provides a JSON API for the complete monitor lifecycle.
- Keeps the browser UI deliberately small: Bootstrap, Stimulus, Twig, and Symfony AssetMapper; there is no frontend build pipeline.
The dashboard creates monitors, displays current prices and scheduling information, and exposes the start, stop, retry, and delete actions.
Zone monitors stay in a preparation state while Playwright discovers the filters. Once ready, one or more zones can be selected before the monitor is started.
| Lowest-price alert | Recommended alert |
![]() |
![]() |
Ticket Watch is a small Symfony monolith with two deliberately separate scraping paths:
- PHP performs recurring listing requests using a browser-like TLS client and a saved cookie.
- Node.js and Playwright perform DOM-driven zone discovery because the zone-to-filter mapping is only exposed through interactions with the event page.
flowchart LR
Operator([Operator]) --> Dashboard[Bootstrap + Stimulus dashboard]
Dashboard --> API[Symfony monitor API]
subgraph Application[Ticket Watch]
API --> Database[(PostgreSQL)]
API --> Queue[[Doctrine Messenger queue]]
Queue --> Worker[Messenger worker]
Worker --> Check[Price check handler]
Worker --> Discovery[Zone discovery handler]
Check --> Parser[Listing parser]
Check --> Notifier[Discord notifier]
Discovery --> Playwright[Node.js + Playwright]
end
Check --> Listings[Viagogo listing request]
Playwright --> EventPage[Viagogo event page]
Notifier --> Queue
Worker --> Discord[Discord webhook]
This layout keeps the web request fast: controllers validate and persist state, then enqueue the expensive work. The same PostgreSQL database stores both application data and Messenger messages, so Redis or another queue service is not required.
stateDiagram-v2
[*] --> Draft: create monitor
Draft --> Active: start all-ticket monitor
Draft --> Discovering: create zone monitor
Discovering --> Ready: zones discovered
Discovering --> Failed: challenge or DOM failure
Failed --> Discovering: retry discovery
Ready --> Active: select zones and start
Active --> Active: schedule next check
Active --> Stopped: stop
Stopped --> Active: start again
Stopped --> Discovering: rediscover zones
Active --> Error: unhandled worker error
Draft --> [*]: delete
Discovering --> [*]: delete
Ready --> [*]: delete
Active --> [*]: delete
Stopped --> [*]: delete
Failed --> [*]: delete
Error --> [*]: delete
- Starting a monitor dispatches a
CheckMonitorMessage. - The Messenger worker reloads the monitor and ignores the message if the monitor is no longer active.
ViagogoClientPOSTs anIndexShGridOnlypayload to the stored event URL using the selected sort mode and optional zone filters.ViagogoListingParserextracts the first listing returned by Viagogo.- The monitor's price, currency, timestamps, and error state are updated.
- If the integer price differs from the previously stored price, a Discord
ChatMessageis enqueued. The first successful check also produces an alert because no previous price exists. - A new
CheckMonitorMessageis scheduled with a Messenger delay matchingintervalSeconds.
Stopping a monitor does not need to remove already queued messages. When a delayed message becomes available, the handler sees that the monitor is stopped and exits without performing a request.
| UI option | API value | Behavior |
|---|---|---|
| Lowest price | NEWPRICE |
Requests Viagogo's lowest-priced first result and labels alerts New Lowest Price. |
| Recommended | RECOMMENDED |
Requests Viagogo's recommended first result and labels alerts Recommended. |
The notification trigger currently compares prices, not listing IDs. If Viagogo changes the first listing but the rounded price remains identical, no new notification is sent.
| Area | Implementation |
|---|---|
| Backend | PHP 8.2+, Symfony 7.4, Doctrine ORM |
| Database | PostgreSQL 16 |
| Queue and scheduling | Symfony Messenger with Doctrine transport and delayed messages |
| Dashboard | Twig, Bootstrap 5.3, Stimulus, Fetch API |
| Browser automation | Node.js, Playwright, Chromium or Google Chrome |
| Listing transport | xxx-bin/php-tls-client with a Chrome-compatible TLS profile |
| Notifications | Symfony Notifier with the Discord bridge |
| Assets | Symfony AssetMapper and Importmap; no webpack, Vite, or npm build step |
| Tests | PHPUnit 13 and Symfony BrowserKit |
| Formatting | Prettier with the PHP plugin |
- PHP 8.2 or newer with Composer.
- Node.js and npm. Node.js 20 or newer is recommended.
- Docker with Docker Compose, or a compatible PostgreSQL installation.
- Chromium or Google Chrome. Playwright can install Chromium automatically.
- A Discord webhook.
- A graphical browser session for the initial Viagogo challenge/session bootstrap.
The repository is currently developed on macOS, but the application code is platform-neutral. The browser and TLS-client dependencies must support the host platform.
git clone git@github.com:JakobAIOdev/ticket-watch.git
cd ticket-watch
cp .env.example .envUpdate .env for the Docker Compose database and your Discord webhook:
APP_ENV=dev
APP_SECRET=replace-with-a-long-random-value
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5433/app?serverVersion=16&charset=utf8"
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
MAILER_DSN=null://null
DISCORD_DSN="discord://DISCORD_WEBHOOK_TOKEN@default?webhook_id=DISCORD_WEBHOOK_ID"The included Compose override publishes PostgreSQL on host port 5433. If PostgreSQL runs directly
on the host or uses a different port, adjust DATABASE_URL accordingly.
A suitable application secret can be generated with:
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'composer install
npm ci
npx playwright install chromiumComposer installs the PHP dependencies and AssetMapper imports. npm is used for Playwright and formatting; there is no frontend compilation step.
docker compose up -d database
php bin/console doctrine:migrations:migrate --no-interactionThe migrations create both the monitor table and the Doctrine-backed messenger_messages queue.
For a simple all-ticket setup, open an interactive browser and save a valid session:
npm run fetch-session -- --fresh 'https://www.viagogo.com/'
mkdir -p var/viagogo
cp var/viagogo-cookie.txt var/viagogo/viagogo-cookie.txtWait until Viagogo works normally in the opened browser, then return to the terminal and press Enter. The copy step is currently required because the generic session helper and the Symfony service use different cookie locations.
If zone monitoring is required, also complete the interactive zone bootstrap.
Use two terminals:
# Terminal 1
symfony server:start# Terminal 2
php bin/console messenger:consume async -vvOpen the URL printed by Symfony CLI, normally http://127.0.0.1:8000.
If Symfony CLI is not installed, PHP's development server can be used instead:
php -S 127.0.0.1:8000 -t public public/index.phpViagogo may require cookies created by a real browser and may present an anti-bot challenge. Ticket Watch does not solve that challenge automatically. The operator completes it interactively once, and subsequent listing checks reuse the saved cookie.
| Path | Purpose |
|---|---|
var/viagogo/viagogo-cookie.txt |
Cookie header consumed by the Symfony listing client. |
var/viagogo/browser-profile-en-US/ |
Persistent Playwright profile used by automated zone discovery. |
var/viagogo/browser-session.json |
Session metadata and challenge status from zone discovery. |
var/viagogo/discovered-zones.json |
Most recent standalone zone-discovery result. |
var/viagogo/zone-discovery/ |
HTML and control snapshots written when discovery fails. |
var/viagogo-cookie.txt |
Cookie written by the generic fetch-session helper before it is copied. |
All files below var/ are ignored by Git. They can contain sensitive session information and must
never be committed or shared.
npm run fetch-session -- --fresh 'EVENT_URL'
mkdir -p var/viagogo
cp var/viagogo-cookie.txt var/viagogo/viagogo-cookie.txtUse a full Viagogo event URL when the homepage alone does not establish all necessary cookies.
Ticket Watch needs both processes to remain alive:
| Process | Command | Responsibility |
|---|---|---|
| Web server | symfony server:start |
Dashboard, JSON API, and static assets. |
| Worker | php bin/console messenger:consume async -vv |
Zone discovery, delayed listing checks, and outgoing Discord messages. |
After changing PHP code, restart the long-running worker so it loads the new classes:
php bin/console messenger:stop-workers
php bin/console messenger:consume async -vvMessenger's stop command is graceful: the worker finishes its current message before exiting.
- Enter a descriptive monitor name.
- Paste the full Viagogo event URL.
- Choose a check interval.
- Select All tickets.
- Choose Lowest price or Recommended.
- Create the monitor, then select Start.
The first completed check stores the current price and sends an initial Discord alert.
- Select Selected zones while creating the monitor.
- The monitor is saved as a draft and zone discovery is queued automatically.
- Wait for the status to become Ready. The dashboard refreshes every five seconds while it is visible.
- Select one or more discovered zones.
- Start the monitor. The selected zone mappings are merged into one filter object and persisted.
Active zone monitors display the zones chosen at start time but do not allow live editing. Stop the monitor before changing the selection or rerunning discovery.
The Playwright script opens Viagogo's filter panel, finds visible zone checkboxes, clicks each zone,
and captures the sections, ticketClasses, rows, and seats identifiers from the resulting URL
or grid request.
The Symfony worker runs discovery as:
node scripts/discover-zones.mjs --headless --locale=en-US --json EVENT_URL
Headless discovery works only after the persistent profile is trusted. Run the same locale once in a visible browser before relying on the worker:
npm run discover-zones -- --fresh --locale=en-US 'EVENT_URL'If a challenge appears, solve it in the browser and press Enter in the terminal when prompted. The
script then saves the profile and application cookie below var/viagogo/.
npm run discover-zones -- --help
npm run discover-zones -- --json --locale=en-US 'EVENT_URL'
npm run discover-zones -- --headless --json --locale=en-US 'EVENT_URL'
npm run discover-zones -- --include-sections 'EVENT_URL'
npm run discover-zones -- --keep-url-filters 'EVENT_URL'
npm run discover-zones -- --with-english --locale=de-DE 'EVENT_URL'| Option | Effect |
|---|---|
--json |
Writes only JSON to stdout; progress messages go to stderr. |
--fresh |
Deletes the selected persistent browser profile before launch. |
--headless |
Runs without a visible browser and fails if a challenge is detected. |
--include-sections |
Includes labels beginning with Section or Bereich. |
--keep-url-filters |
Preserves existing filter parameters from the supplied URL. |
--locale=LOCALE |
Sets browser locale and Accept-Language; defaults to en-US. |
--profile-dir=PATH |
Overrides the persistent profile directory. |
--with-english |
Performs an additional en-US pass and merges English labels by filter IDs. |
Create a webhook in the desired Discord channel. A Discord webhook URL has this shape:
https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN
Convert it to the Symfony Notifier DSN used in .env:
DISCORD_DSN="discord://WEBHOOK_TOKEN@default?webhook_id=WEBHOOK_ID"Do not commit the webhook URL, ID, token, or generated .env file.
New Lowest Price · Event NameforNEWPRICEmonitors.Recommended · Event NameforRECOMMENDEDmonitors.- The title links to the event page.
- Price changes are rendered as
old → new; the first alert contains only the current price. - Ticket metadata is arranged into compact Discord fields.
- Open exact listing → links to the event URL with
listingIdandlistingQtyparameters. - A Viagogo thumbnail is always included.
- The listing's
vfsUrlvenue/seat image is included when Viagogo provides one. - The footer includes the monitor ID and alert timestamp.
Discord messages are themselves routed through the async transport. If the worker is stopped, the alert remains queued until a worker resumes processing.
All endpoints accept and return JSON unless noted otherwise. The dashboard is a client of the same API.
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/monitors |
List all monitors, newest first. |
POST |
/api/monitors |
Create a draft monitor and optionally queue zone discovery. |
GET |
/api/monitors/{id} |
Retrieve one monitor. |
PATCH, PUT |
/api/monitors/{id} |
Update name, interval, sort mode, or selected zone filters. |
POST |
/api/monitors/{id}/start |
Activate a monitor and queue its first check. |
POST |
/api/monitors/{id}/stop |
Stop an active monitor. |
GET |
/api/monitors/{id}/zones |
Return completed zone discovery results. |
POST |
/api/monitors/{id}/zones/discover |
Clear and rerun discovery for a stopped zone monitor. |
DELETE |
/api/monitors/{id} |
Permanently delete a monitor; returns 204 No Content. |
curl -X POST http://127.0.0.1:8000/api/monitors \
-H 'Content-Type: application/json' \
-d '{
"name": "Don Toliver Munich",
"sourceUrl": "https://www.viagogo.com/.../E-161443419?quantity=2",
"intervalSeconds": 60,
"ticketScope": "all",
"filterMode": "NEWPRICE"
}'| Field | Type | Rules |
|---|---|---|
sourceUrl |
string | Required, non-empty, maximum 1,000 characters. |
name |
string | Required, non-empty, maximum 255 characters. |
intervalSeconds |
integer | Minimum 10; defaults to 60. |
ticketScope |
string | all or zones; defaults to all. |
filterMode |
string | NEWPRICE or RECOMMENDED; normalized to uppercase. |
curl -X POST http://127.0.0.1:8000/api/monitors/1/start \
-H 'Content-Type: application/json' \
-d '{}'Wait until zoneDiscoveryStatus is completed, then submit at least one usable filter array:
curl -X POST http://127.0.0.1:8000/api/monitors/2/start \
-H 'Content-Type: application/json' \
-d '{
"selectedZoneFilters": {
"sections": ["141765"],
"ticketClasses": ["21217"],
"rows": ["395659"],
"seats": []
}
}'curl -X PATCH http://127.0.0.1:8000/api/monitors/1 \
-H 'Content-Type: application/json' \
-d '{
"name": "Munich — recommended",
"intervalSeconds": 300,
"filterMode": "RECOMMENDED"
}'The update endpoint does not currently change sourceUrl or ticketScope.
{
"id": 1,
"name": "Don Toliver Munich",
"sourceUrl": "https://www.viagogo.com/.../E-161443419?quantity=2",
"status": "active",
"intervalSeconds": 60,
"lastPrice": 96,
"currency": "EUR",
"lastCheckedAt": "2026-07-21T20:30:00+02:00",
"nextCheckAt": "2026-07-21T20:31:00+02:00",
"createdAt": "2026-07-21T20:20:00+02:00",
"stoppedAt": null,
"lastError": null,
"availableZones": null,
"zoneDiscoveryStatus": "not_required",
"zoneDiscoveryError": null,
"selectedZoneFilters": null,
"ticketScope": "all",
"filterMode": "NEWPRICE"
}Monitor statuses used by the current implementation include draft, ready, active, stopped,
failed, and error. Zone discovery separately uses pending, running, completed, failed,
and not_required.
Validation failures return HTTP 400 with a stable shape:
{
"error": "Validation failed",
"violations": [
{
"field": "intervalSeconds",
"message": "This value should be greater than or equal to 10."
}
]
}.
├── assets/
│ ├── controllers/monitor_controller.js Dashboard behavior and API client
│ └── styles/app.css Small layer over Bootstrap
├── config/
│ ├── packages/messenger.yaml Async and failed transports
│ └── services.yaml Viagogo service configuration
├── docs/ README screenshots
├── migrations/ Monitor and Messenger database schema
├── scripts/
│ ├── discover-zones.mjs Playwright zone mapping
│ ├── fetch-session.mjs Interactive cookie bootstrap
│ └── format-staged.mjs Pre-commit formatter
├── src/
│ ├── Controller/ Dashboard and monitor API
│ ├── Dto/Monitor/ Request normalization and validation
│ ├── Entity/Monitor.php Persisted monitor state
│ ├── Message/ Queue message contracts
│ ├── MessageHandler/ Check and discovery workers
│ └── Service/
│ ├── Notification/ Discord embed construction
│ └── Viagogo/ Request, parsing, filters, and links
├── templates/ Twig dashboard templates
├── tests/ PHPUnit unit and controller tests
└── viagogo-scraper-lab/ Separate experimental scraper harness
The viagogo-scraper-lab/ directory predates the integrated service and remains useful for isolated
fetching and parser experiments. It has its own dependencies and README.
php bin/phpunitThe suite covers the dashboard, request DTO validation, payload construction, listing parsing, exact-listing URL generation, and Discord payload composition.
npm run format:check
npm run formatThe repository uses Prettier for JavaScript, JSON, Markdown, CSS, Twig, YAML, and PHP. A local
pre-commit hook can format staged supported files through npm run format:staged.
php bin/console lint:container
node --check scripts/discover-zones.mjs
php bin/console debug:routerphp bin/console doctrine:migrations:status
php bin/console messenger:stats
php bin/console messenger:failed:showFor anything beyond local use:
-
Set
APP_ENV=prod, disable debug mode, and use a strongAPP_SECRET. -
Put the application behind HTTPS and authentication. The current dashboard and API are not access-controlled.
-
Store
DATABASE_URLandDISCORD_DSNin a secret manager or injected environment variables. -
Persist
var/viagogo/securely if browser profiles and cookies must survive deployments. -
Run database migrations during deployment.
-
Compile production assets:
php bin/console asset-map:compile
-
Run
messenger:consume asyncunder systemd, Supervisor, a container orchestrator, or another process manager with automatic restart behavior. -
Monitor the failed transport and application logs.
-
Keep check intervals conservative; every active monitor creates recurring external requests.
- Confirm
php bin/console messenger:consume async -vvis running. - Check
lastErroron the monitor card or throughGET /api/monitors/{id}. - Inspect
php bin/console messenger:statsandphp bin/console messenger:failed:show. - Verify that
var/viagogo/viagogo-cookie.txtexists and is not empty.
Messenger workers are long-running processes and retain the old classes in memory. Restart them:
php bin/console messenger:stop-workers
php bin/console messenger:consume async -vvThe worker intentionally fails rather than attempting to bypass the challenge. Run interactive discovery using the same locale and profile, solve the challenge, then select Retry discovery in the dashboard:
npm run discover-zones -- --fresh --locale=en-US 'EVENT_URL'- Confirm the event actually exposes zone filters.
- Retry without stale URL filters; the script clears them by default.
- Use
--include-sectionswhen the desired controls are section-level labels. - Inspect the HTML and JSON snapshots in
var/viagogo/zone-discovery/. - Viagogo DOM changes may require selector updates in
scripts/discover-zones.mjs.
The included Compose override maps PostgreSQL to 127.0.0.1:5433. Use port 5433 in
DATABASE_URL, or change the Compose port mapping to match the environment.
The current parser pairs rawPrice with Viagogo's listingCurrencyCode. For cross-currency
listings, Viagogo may return a buyer-displayed price in EUR while listingCurrencyCode reflects the
seller's GBP or USD listing. Until the parser uses buyerCurrencyCode, treat cross-currency labels
as a known correctness limitation.
- Ensure only the intended number of Messenger workers is running.
- Check for delayed or failed
ChatMessagerecords in the Doctrine transport. - Remember that an alert is queued separately after the listing check; it may arrive slightly later.
- The listing request and zone discovery depend on undocumented Viagogo behavior and can break without notice.
- Cookies expire and may need manual renewal.
- Anti-bot challenges require a human-controlled browser. This project does not include automated challenge solving.
- Zone discovery depends on visible labels, DOM structure, and URL/request side effects.
- Currency handling currently prefers the seller's
listingCurrencyCode; see troubleshooting. - Prices are rounded to integers before comparison, so sub-unit changes are ignored.
- Alerts compare the first listing's price, not listing identity or the complete result set.
- There is no authentication, authorization, rate limiting, audit log, or multi-user isolation.
- Deleting a monitor is permanent.
- The UI polls the monitor list every five seconds while the tab is visible.
- Use conservative intervals and comply with all applicable website terms, laws, and policies.
composer.json currently declares this repository as proprietary, and no open-source license is
provided. No permission to copy, modify, or redistribute the code is granted by default.



