Saturday, 22 August 2026

Building an Automation Hub for a Ticket-Resale Business in .NET

 This is the first post in a series about a project I built for a US-based ticket-resale company: a .NET "automation hub" that connects more than 10 different systems — from POS and marketplaces to banking APIs and an AI that reads email.

The problem

The event-ticket resale business in the US runs on a patchwork of disconnected platforms: SkyBox (VividSeats' POS for inventory/invoices), Lysted (a resale marketplace), Ticket Evolution, TicketUtils, SeatScouts/AutoQ (purchasing-account management), plus banking (Taekus), Slack, Gmail, and Google Sheets. Every day the operations team had to:

  • Reconcile Lysted sales against inventory in SkyBox
  • Record settlement payments from remit CSV files onto individual invoices
  • Read hundreds of marketing/presale emails so no new ticket drop was missed
  • Monitor balances and sweep cash-back into the main account
  • Bulk-configure a fleet of purchasing accounts

All of it repetitive, all of it error-prone when done by hand. My goal: fold everything into one system that runs automatically on a schedule.

Architecture: one console app, many "sub-programs"

Instead of standing up a fleet of separate services, I went with a model that is simple but extremely effective for internal automation: a single .NET console app dispatched by command-line argument, driven by Windows Task Scheduler.

switch (command)
{
    case "LystedSynchToSB":      // Sync Lysted sales → SkyBox
    case "LystedPaymentCSV":     // Reconcile settlement CSV → invoices
    case "EmailAIAlerts":        // AI reads & classifies email
    case "EmailAITraining":      // Fine-tune the email classification model
    case "TaekusWithdrawPersonalAcc": // Auto-sweep cash-back
    case "GetEvoAccount":        // Withdrawal + reporting for Ticket Evolution
    case "AccessGoogleSheets":   // Sync AutoQ ↔ MySQL ↔ Sheets
    // ... ~25 more commands
}

Each command is an independent "sub-program" in the Businesses layer, sharing common infrastructure: configuration via App.config, logging with Serilog (console + file), data access through Entity Framework + MySQL, and a dedicated DAL layer for the heavier queries.

The main building blocks

1. Data sync & reconciliation

A group of jobs pulls Purchases / Invoices / Inventory / Vendors from the SkyBox API into MySQL, with a follow-up "enrichment" pass. The highlight is the settlement reconciliation job: it reads Lysted's remit CSV (via CsvHelper), looks up the matching SkyBox invoice by external reference, skips invoices already marked PAID, and otherwise injects an ACH payment and PUTs it back to the SkyBox API — logging every result to Google Sheets so the finance team can verify.

2. AI that reads email (the fun part)

The team's inbox receives hundreds of emails a day from Ticketmaster, venues, fan clubs, and more. I built a pipeline: IMAP reads new mail → the content (including inline images — multimodal) goes to an LLM → structured JSON comes back (performer, venue, event date, notification type, links, promo codes...) → results are grouped and sent to the team as digest emails. The system supports two interchangeable AI providers (a fine-tuned OpenAI GPT-4o-mini and Google Gemini) behind the same JSON contract. Full details in posts 2 and 3 of this series.

3. Fintech automation

Two jobs move real money: they check balances on Taekus and Ticket Evolution, and when cash-back or spendable balance is available, they automatically create a withdrawal through the API, then audit-log everything to Google Sheets and post a Slack notification. The design principle: fetch → decide → transact → record → notify, with every transaction leaving a trail in at least two places.

4. Slack as a data source

Rather than waiting for a webhook from Lysted (there isn't one), I took advantage of the fact that "tickets sold" emails were already being forwarded into Slack: a bot reads conversations.history, downloads the attachments, parses the HTML with HtmlAgilityPack, and matches the results against SkyBox inventory. "The HTML email is the only API" — post 4 tells that scraping story, including what broke when Lysted changed their template.

Architecture lessons

  • A console app + Task Scheduler beats microservices for internal automation: one artifact to deploy, one place to debug, and adding a job is just adding a case.
  • Google Sheets is the cheapest UI for a non-technical operations team — every job writes its results to Sheets so a human can inspect them.
  • Slack is the central alerting channel: every job ends with a Block Kit message summarizing the outcome.
  • Deliberate human-in-the-loop boundaries: on the refund sheet, for example, the bot fills in only the columns it can extract with confidence and leaves the rest blank for the finance team — the human/machine boundary is written down explicitly in the code.

Coming up in this series:

  1. Classifying event-ticket emails with AI: from prompt engineering to fine-tuning GPT-4o-mini
  2. Defending against LLM output: parsing "dirty" JSON in C#
  3. Syncing Lysted → SkyBox through Slack: when an HTML email is the only API

Tech stack: .NET (C#), Entity Framework, MySQL, Serilog, MailKit/MimeKit, HtmlAgilityPack, CsvHelper, RestSharp, Google Sheets API v4, Slack API, OpenAI API (fine-tuning), Google Gemini API.

Four Months Building a Domain Research Platform: What I Learned



Earlier this year I set out to build a SaaS platform for domain name research — a tool that helps investors find newly registered domains and expiring auctions worth buying, backed by real business signals instead of gut feeling. Four months and a hundred-plus commits later, it's live. Here's what the journey actually looked like.

What the platform does

The core idea: a domain name is more valuable if real companies are already using that name. So the platform cross-references millions of domain candidates against company datasets — LinkedIn company data, Crunchbase, funding records — and surfaces metrics like "how many companies use this keyword" and "does the .com resolve to a funded startup." Users can filter new registrations and live auctions by dozens of these signals, save filter sets, and even schedule automatic bids on auctions.

Tech stack: React 19 + Vite on the frontend (deployed to Azure Static Web Apps), ASP.NET Core 8 API on the backend, SQL Server for the data.

Lesson 1: At 13 million rows, every query is a performance problem

My biggest recurring theme was SQL performance. The candidates table holds over 13 million rows, and innocent-looking filters would take tens of seconds. Some things that saved me:Denormalize aggressively. Joining a 13M-row table against per-keyword metrics on every page load was never going to work. Copying the hot metric columns onto the candidate rows (updated in batch) turned multi-second queries into instant ones.
Filtered indexes have sharp edges. I had a filtered index that looked perfect but the optimizer couldn't use it for a scan pattern in a sync procedure — that one anti-join was reading 13 GB off the clustered index. Adding a plain non-filtered index cut reads by 10x.
Cap your counts. "Showing 1 of 4,381,022 results" is a nice flex, but computing that exact count on every filter change is expensive. Capping the count query made the filter UI feel instant.

Lesson 2: Third-party data is always staler than you think

The auction feature schedules bids near an auction's end time. Sounds simple — until a user's bid gets rejected with "auction already ended" on an auction that's clearly still live. The cause: the end times I'd cached from the auction provider had drifted. Auctions get extended, removed, and rescheduled constantly.

The fix was layered: re-check the live end time from the provider's API at the moment a bid is scheduled, run a recurring background job to refresh end times for anything with a pending bid, and track an explicit auction status so removed auctions don't masquerade as active ones. The lesson generalizes: any cached copy of someone else's real-time data needs a freshness strategy, not just a sync job.

Related sub-lesson: store everything in UTC, convert at the edges, and let users pick an IANA timezone (falling back to the browser's). I've yet to regret a UTC decision; I've regretted every alternative.

Lesson 3: Background jobs beat polling

Auto-bidding started as scheduled tasks checking "is it time to bid yet?" — wasteful and imprecise. Moving to Hangfire with event-driven, per-auction scheduled jobs made bids fire at exactly the right moment and made the whole thing observable: each auction stores its job ID, so I can see, cancel, or reschedule any pending bid.

Lesson 4: The cloud will hurt you in ways the docs don't mention

Two incidents worth sharing:Azure Static Web Apps just... stopped accepting deployments. Server-side rejections with no useful error, for days. I ended up building a manual deploy recipe as a workaround. Sometimes the fix is a runbook, not a root cause.
ASP.NET Core's DataProtection key ring silently regenerated on App Service Linux, which meant every encrypted secret in the database (users' API credentials for the bidding integrations) became undecryptable overnight. The default key storage is ephemeral in containers. Persisting keys to the database via PersistKeysToDbContext fixed it — but only after users had to re-enter credentials. If you encrypt anything with DataProtection, decide where the keys live before production.

Lesson 5: Multi-tenancy is a hundred small decisions

Turning an internal tool into a licensable product meant feature entitlements: internal users see everything, external users see what their plan includes, and premium data sources get gated field-by-field in API responses — not just hidden in the UI. Doing this properly took eight phases of work and touched nearly every endpoint. My advice: design the entitlement model early, even if you launch with one plan. Retrofitting gating onto finished endpoints is slow, careful work.

What's next

Subscription billing with Stripe, and a data pipeline to generate my own TLD zone statistics from ICANN zone files instead of depending on third-party counts. But that's the next post.

Building an Automation Hub for a Ticket-Resale Business in .NET

  This is the first post in a series about a project I built for a US-based ticket-resale company: a .NET "automation hub" that co...