Case study

Open Call Manager

A multi-tenant SaaS for submission intake, review workflows and marketplace payments. Four months from research to MVP, system-designed and run by me, and built with AI.

research → MVP → post-MVP
4 months
records from 7 custom scrapers
6,400+
competitors analyzed
41
commits across 5 repos
~900
backlog tasks, ~170 added as I steered
214
test files: unit, database, e2e
127

Timeline

Four months, month by month

Bars show backlog tasks created each month: about 45 planned up front, then about 170 added as I steered.

  1. July 2026

    Specs and first build

    Docs of record and the pre-build decision session; build starts; scrapers begin.

  2. August 2026

    Core build

    Blind review, checkout and the media pipeline; Codex PR reviews begin; Docker → PM2.

  3. September 2026

    Research and refinement

    Market reports from scraped data; 41 competitors profiled; marketing review.

The product

Organizations such as galleries, festivals and grant programs run open calls: time-boxed public submission campaigns. Technically, Open Call Manager is a vertical SaaS that combines:

Configurable intake
Per-campaign forms, eligibility rules, file requirements and deadlines.
Applicant tracking
A CRM-style pipeline (draft → submitted → vetted → judged → awarded) with bulk triage.
User management and RBAC
Four roles (owner, admin, judge, applicant), scoped per organization and per campaign, not global.
Review workflow
Judge assignment, configurable scoring schemes, and blind review enforced at the data layer.
Payment gateway and payouts
Entry-fee checkout, marketplace-style settlement to each organization, platform commission.
Async media processing
Upload normalization and derivatives for images, PDFs, audio and video.
Transactional email
Invites, receipts and results.

Revenue model: a commission on collected entry fees, with a free tier for no-fee campaigns. I chose it over a subscription so there's no cost to adopt and revenue tracks usage. It drove the payments architecture: Stripe Connect and a per-tenant fee rate.

In the product

Screens from the running app with seed data: an organizer creating a call, a judge scoring an entry, and an artist building an entry on a phone.

Create a call dialog: a title field filled in with Fall Members' Exhibition 2027, and a grid of ten recipes to start from, such as Standard open call, Group show (selected), Residency, Art fair or festival and Film festival, with a summary of what the selected recipe sets up.
Creating a call. Organizers start from a recipe that sets the entry rules, piece fields and fees for that kind of call, then change anything it sets.
Judging workspace for the call Works on Paper: a submitted artwork of six ink portraits on pink paper on the left, with its kind, medium and year below, and a scoring panel on the right with buttons 1 to 5, keyboard shortcut hints, a progress bar and entry navigation.
Judging. Judges score one entry at a time with the call's scheme (here 1 to 5 stars), by click or keyboard, and only see what blind review allows.
Artist entry on a phone, step 1 of 3: the Small Works Open call, a progress bar, what the call accepts, and a drop zone to browse or reuse files, with Save draft and Continue buttons fixed at the bottom. The same entry scrolled to a piece: a thumbnail of the artwork Low Tide Study marked Ready, with fields for kind of work, medium, year completed, dimensions and a description for screen readers.
Entering a call. Artists work through guided steps on any screen size; every uploaded file becomes a piece with the details the call asks for.

Research: data before design

Data collection

Seven scrapers, one per source, because each source needed a different technique.

Scraped sources, record counts and techniques
SourceRecordsTechnique
CaFÉ2,896Public GraphQL API
Submittable2,053REST list, then resumable per-record enrichment
EntryThingy938Cloudflare-protected; human-in-the-loop browser export
ArtConnect352JSON pulled from the Next.js RSC payload (no API)
ArtCall129Static page, de-duplicated
NYFA53Open REST API behind a Cloudflare-protected site
Linktree43Kagi Search API discovery + __NEXT_DATA__ parsing

Every scraper paces its requests with randomized delays, backs off on 429s and Retry-After, resumes after interruption, caches paid API queries, and can run behind a rotating VPN. Output goes to SQLite, with aggregate reports on top.

Findings that shaped the product

Share of campaigns charging an entry fee (%)
SourcePercent charging a fee
CaFÉ 72%
EntryThingy 69%
Submittable (USD) 52%
Entry fee levels on CaFÉ (2,187 listed fees; median $30)
Fee rangeNumber of fees
$1–10 98
$11–20 286
$21–30 727
$31–40 829
$41–50 196
$51–75 44
$76+ 7
Where 352 ArtConnect apply links lead
DestinationListings
Own site or form 195
No apply link 52
Google Forms 30
Picter 22
Submittable 20
Other platforms 33
  • Most campaigns charge an entry fee, so payments are core to the product, not an add-on.
  • More than half of apply links go to bespoke sites or generic form tools, not to any submission platform. That's the addressable gap.
  • Requirements are inconsistent (for example "300 dpi" for images viewed on screen), so the platform normalizes media server-side instead of pushing that work onto applicants.

Competitive analysis

I profiled 41 platforms from a written research plan and stored the results as structured data: profiles, pricing tiers, features, constraints and language support. 31 were fully profiled, and 10 were flagged for manual review rather than guessed at.

Competitor categories
CategoryExamples
Purpose-built submission managementSubmittable, CaFÉ, SlideRoom, ShowSubmit, Zealous
Vertical (festival management)FilmChief, Festhome, Eventival
CRM / grant-management modulesLGL Forms, Procurios, Temelio
Generic form buildersGoogle Forms, JotForm, Typeform, Airtable, Tally
Competitor pricing models
ModelExamples
Per campaign + per applicationCaFÉ, ShowSubmit
Subscription + transaction commissionZealous
Flat annual licenceGrantPlatform, Eventival, FilmChief
Quote-only enterpriseSubmittable, SlideRoom, SmartSimple
Free for organizations, applicant paysFesthome
Per CRM contactLGL Forms, Procurios
Per seatAirtable
Tiered by response volumeJotForm, Typeform, Tally, Fillout

The gap: every form builder handles intake. None has a review workflow, an applicant status portal or award tooling. The form is a commodity; the review pipeline is the product.

Only one competitor confirmed blind review, and none confirmed per-field blind review.

Specs and planning

Compartmentalized specs

Before writing code, I split the system into specifications with one concern each and a clear owner for every decision. Each one is a document of record that other specs reference rather than repeat.

Specifications, what each owns and what it does not
SpecOwnsDoesn't own
Product and business rulesCampaign lifecycle, roles and permissions, fee and refund rules, notificationsHow any of it is implemented
System architectureService boundaries, data model, auth model, payment flow, deployment topology, disaster recoveryBusiness policy
Media pipelineAccepted formats, normalization, derivatives, queue contract, worker hardeningWeb app behavior
Test strategyWhat gets tested at which layer (unit, database/RLS, e2e, worker)Feature scope
Seed dataRealistic multi-tenant fixtures for every role and stateProduction data
Requirements trackerEach requirement marked resolved, open, deferred or out of scopeThe details, which link to the spec that owns them
Delivery planMilestones, sequencing, deliberate scope cutsDesign decisions

All of them were generated in Claude Code plan mode (a practice I adopted while building Sprogeny), challenged by Google Gemini as a second opinion, and changed only through dated amendments, so the history of each decision stays visible.

Domain decisions, made deliberately

A pre-build decision session went through every open question across the specs. The AI restated each one and laid out options with a recommendation; I decided each one, or deferred it with a trigger for revisiting it.

Domain decisions with rationale
QuestionDecisionWhy
When is an entry "submitted"?Only on a verified payment webhook; the UI shows "processing" until thenThe webhook is the source of truth; a redirect can be faked or lost
How does blind review work?One database view that masks identity columns per campaignEnforced where data is read, not in the UI
How are judging rounds modeled?A round-membership table, kept separate from entry stateMulti-round support added later without reshaping entries
Who owns an entry?Separate artist and submitter IDsLeaves room for agents or representatives later without a migration
Checkout granularity?One checkout session per entry, line items per pieceClean receipts and refunds per entry
Require banking at signup?No; payment onboarding happens on an organization's first paid campaignRemoves the biggest onboarding hurdle for free users
Launch region?US-only, USD-onlyOne currency and one tax regime until there is demand
Schedule slips?Don't cut scope; let the timeline absorb itQuality over a date that only I set

Architecture

  1. 1 Two domains opencallmanager.com (organizations, judges) and shareyour.art (applicants)
  2. 2 nginx TLS, forwarded headers, routing to the app
  3. 3 SvelteKit on Node One deploy, hostname-routed into two route trees
  4. 4 Supabase Postgres, Auth, row-level security
  5. 5 Stripe Checkout per entry, Connect settlement to each organization
  6. 6 RabbitMQ Job queue between the web and media tiers
  7. 7 Python/Celery worker ffmpeg and ImageMagick, dead-letter queue
  8. 8 DigitalOcean Spaces S3-compatible object storage; SendPulse for email

Multi-domain routing

One deploy serves two branded domains. A reroute hook maps the hostname to a route tree, and sessions are deliberately kept per domain.

export const reroute: Reroute = ({ url }) => {
  if (url.pathname.startsWith('/api')) return;
  const area = resolveHostArea(url.hostname);
  if (area) return `/${area}${url.pathname}`;
};
app/src/hooks.ts

Authorization in the database

Scoped roles are enforced with Postgres row-level security and column-level grants. Blind review masks identity fields per campaign in a view, so the UI can't leak what the query never returns.

case when oc.blind_judging ? 'artist_id'
  then null else e.artist_id end as artist_id,
case when oc.blind_judging ? 'full_name'
  then null else p.full_name end as full_name,
Blind-review view (excerpt)

Payments

Stripe Checkout per entry, confirmed by webhook and recorded in a payments ledger. Stripe Connect direct charges settle to each organization's account, with the platform fee deducted at charge time.

Async jobs

Browser uploads go straight to object storage through presigned URLs. RabbitMQ queues a job, and the Celery worker normalizes the file and builds derivatives under a hardened ImageMagick policy, because uploads are untrusted input.

Next: AI content moderation. TypeSafe Jev will classify campaign and submission text for spam and policy violations, such as requests for off-platform payment, ahead of human vetting.

Systems design

Service boundaries
The web tier (SvelteKit) and media tier (Python worker) talk only through a queue and object storage, so they deploy, scale and fail independently.
Consistency
Payment state changes only on verified webhooks; webhook event IDs are recorded so a redelivered event isn't applied twice.
Idempotency
Refunds use one idempotency key per entry, so a withdrawal racing a campaign cancellation can't refund twice; cancellation itself is safe to re-run.
Failure handling
Failed media jobs go to a dead-letter queue rather than being dropped, so they survive an outage and can be replayed.
Security boundaries
Authorization lives in Postgres (RLS, column grants, masking views); uploads are untrusted and processed under a restricted ImageMagick policy.
Data lifecycle
Account deletion is a queued, re-runnable process that is re-applied after a backup restore; payment records are kept per retention policy.
Scaling path
Shared VPS → dedicated droplets per service → replicated droplets for horizontal scale. The queue boundary is what makes the split cheap.

Stack decisions, from experience

Technology choices, prior experience and rationale
ChoicePrior experienceRationale
SvelteKit + TypeScriptlucidbeaming.com refactor; Sprogeny rewrite from Flask to SvelteKitProductive full-stack TypeScript; mature Node SDKs for Stripe and SendPulse
Python workerArt Review Generator; years of ffmpeg and ImageMagickThe best bindings for media tooling and RabbitMQ
RabbitMQArt Review Generator (since 2019): web app → GPT-2 worker, now distributed inference on a Raspberry Pi 4Proven decoupling of slow jobs from the web tier
SupabaseFound through an online Svelte courseManaged Postgres across environments, a local CLI for LLM-assisted development, built-in Auth
StripeMy contracting business since 2017, first in PHPUseful developer docs; a fair, effective product with marketplace support
DigitalOceanMy host for 15+ yearsOne provider from a single droplet to horizontal scale

Where the experience came from

Engineering practice

Repo structure for a team

A parent repo with five submodules (app, media worker, message queue, docs and brand), each versioned and deployed independently. Work reaches main from dev only through reviewed pull requests. A CLAUDE.md in every repo encodes the conventions for humans and agents alike, and a Backlog.md board holds 214 tasks across 19 milestones, each task becoming a branch and a PR.

Delivery: four months, steered by hand

I ran the project myself for four months, monitoring every task and adjusting the plan as I learned. The initial plan was a deliberately aggressive 14-day sprint of about 45 tasks; its real value was forcing priorities to be decided early. When the target shifted for business reasons, I immediately raised the bar on security and accessibility instead of just extending the dates. About 170 more tasks were added as audits, testing and launch preparation exposed new work. The MVP shipped on 5 October 2026.

See the month-by-month timeline ↑

Course corrections, from experience

  • Docker → PM2 for production. A natural progression: memory use and library loading and release are managed by the OS for each process, instead of everything sitting under one Docker umbrella, and port management and forwarding are much cleaner.
  • A config constant instead of a settings table. There is no super-admin role to edit a settings table, so the platform default became configuration.
  • Media in stages. Images first, then PDF and audio; video stays link-only.
  • Rounds in the schema on day one. Multi-round judging could land later without a migration.
  • Audits before launch. Dedicated accessibility and security audits ran before release, not after.

Side quests alongside the main tasks

  • Superdesign. Exploring complex admin screens before building them.
  • Tiered deployments. Resource distribution designed to scale out, not just up.
  • Code audits. Cutting redundant tests, closing security leaks, and moving code toward Svelte-first design.

Infrastructure evolution on DigitalOcean

  1. 1 · Done Local development in Docker
  2. 2 · Done Staging droplet running Docker images
  3. 3 · Done Shared VPS with PM2-managed processes
  4. 5 · Planned Replicated droplets for horizontal scaling

Quality

Tests
80 unit (Vitest) · 26 database (pgTAP: RLS and roles) · 21 e2e (Playwright, both domains)
Migrations
68, each reviewed like code
Accessibility
WCAG 2.2 AA audit. Page loads with serious or critical axe violations went from 37 of 48 to 0 of 27, and axe now gates the e2e suite.
Security
Database and API attack-model audit; every high finding fixed, mediums tracked.

Design: user-centric, not decorative

I designed the logo and set the colors and typography. One token-based design system runs at two densities: compact for organizers, relaxed for applicants. The admin side deliberately matches the tools organizers already know, so there's nothing new to learn. The visual design is kept basic for legibility, including for people with low vision, and the artist entry flow is mobile-first, with good layouts for larger screens.

One example: the brand amber was only 2.2:1 against white, so the focus ring uses a darker amber at 5.05:1 that keeps the brand feel and meets WCAG.

How I work with AI

Each model has one job, and work written by one model is reviewed by another, and by me. Different models have different blind spots.

Claude Code
Builder: plan-mode specs, implementation, tests, research runs, audits; Playwright MCP for browser checks
Google Gemini
Second opinion on plans and docs
OpenAI Codex
Reviews every PR with prioritized findings (P1/P2), alongside my own review
Mistral Vibe
Independent code review passes
Superdesign
Design exploration for complex admin screens, to my direction
TypeSafe Jev
Next: in-product moderation of campaign and submission text

The review loop, with me in the middle

  1. 1 Claude Code implements a backlog task and opens the PR.
  2. 2 Codex reviews the diff and reports findings ranked by priority (P1/P2).
  3. 4 Claude fixes each posted finding in a follow-up commit and replies with the commit, what changed and how it was verified.

Neither model talks to the other directly. I'm the filter between reviewer and builder, so every finding on a PR is one I chose to act on, and none of the models' judgments go unchecked.

PRs in the app repo
107
Codex findings I chose to post
30
threads answered with a verified fix
23

What the findings caught

Concurrency and races
Duplicate piece positions from parallel uploads; a checkout racing a cap change; check-then-update on campaign status
Authorization and data integrity
Media still editable after submission; blind review with no write path; stale agreement wording accepted
Fail-closed behavior
A fee setting resolving to NaN; rule checks passing when a read failed; a seed guard that was optional
Test quality
A tautological money assertion; RLS tests with no denial case; Playwright specs registered twice
Operational safety
A migration that would abort on historical rows; unbounded memory reading a remote image

From the review threads

Money math

The test that couldn't fail

Codex: This platform-fee invariant cannot detect an incorrect fee calculation. orgNet is defined as total - platformCut, making the assertion an algebraic identity: it passes if calculatePlatformFee returns zero, the full total, or any wrong percentage.

Claude: Added a hand-computed expected fee per ledger row. Verified the fix catches a regression by temporarily swapping Math.round for Math.floor in the implementation: the new assertion failed (374 vs 375) where the old check would have passed.

Blind review

Proving a security test works by breaking the code

Codex: Add a denial case for an unaffiliated authenticated user. If the view's authorization WHERE clause were removed, all sixteen masking assertions would still pass while any signed-in user could read judge data.

Claude: Added two denial cases, then reverted the view to its earlier shape with no authorization clause: all 16 original masking assertions stayed green while only the two new denial checks failed.

Concurrent uploads

A race condition fixed with a row lock

Codex: Uploads complete up to three files concurrently. Two requests can both read the same last piece, then both insert position = last + 1; the schema has no unique constraint to reject the tie.

Claude: Added a Postgres function that locks the parent entry row (FOR UPDATE) for the read-increment-insert. Concurrent calls for the same entry now serialize, while different entries don’t contend.

Checkout

The fixer goes further than the reviewer

Codex: When the default fee setting is unset, the application fee becomes NaN, so Stripe rejects every checkout. Fail closed with a clear configuration error.

Claude: Found a gap in the first attempt: Number("") is 0, not NaN, so a blank setting would have silently resolved to a "valid" 0% fee rather than failing closed. Fixed at the source, plus the requested guard.

Piece caps

Payments and limits racing each other

Codex: An entry started under a cap of 10 can be submitted with 8 pieces after the cap is lowered to 5, because an already-open Stripe session can still be paid.

Claude: Checkout now reads the fee and cap under its lock, and lowering a live campaign's cap first releases over-cap checkouts.

Schema changes

Migrations that respect production data

Codex: These CHECK constraints are validated against every existing row; an existing value over the new limit will abort the whole migration.

Claude: Constraints are now added NOT VALID. A DO block counts existing violations, validates when clean, and otherwise warns with the constraint name and row count, so the migration never aborts on historical data.

Why this works: the reviewing model has no stake in the code it reviews, I filter its findings so the builder only acts on ones that matter, the fixing model has to show evidence rather than just agree, and every finding stays on the PR as a record. Mistral Vibe ran independent passes at milestones; its review of the checkout and judge-invite work flagged a non-transactional invite flow, where an email could be sent before the insert failed.

Next, and lessons

Next: production release · TypeSafe Jev moderation · replicated droplets for horizontal scaling.

  • An aggressive plan is still worth writing.

    The 14-day sprint didn't hold, but it forced every priority to be decided up front, and that ordering survived the change of schedule.

  • Use slack to raise quality, not just extend scope.

    When the timeline moved, security and accessibility were the first goals I expanded.

  • Small side quests compound.

    Design exploration, deployment tiers and code audits, done alongside the main tasks, kept the codebase lean and ready to scale.