Install
Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/0xclaudi0/ticket-marketplace-wp/releases/download/v0.1.0/ticket-marketplace-0.1.0.zipA peer-to-peer ticket resale marketplace for WordPress. Fans list tickets they cannot use; buyers purchase them; funds stay in the platform balance until the day after the event.
Built on WooCommerce. Woo owns cart, checkout, tax, order state and refunds. This plugin owns events, listings, ticket-file custody, escrow release, fraud screening, commission, and the per-market compliance engine.
Status
Feature-complete and verified against WordPress 7.0 + WooCommerce 10.9. 381 automated checks across three suites.
| Area | State |
|---|---|
| Event catalogue with face-value tiers | done |
| Listings, product mirroring, oversell protection | done |
| Per-market compliance engine | done |
| Stripe Connect Express seller onboarding | done |
| Admin-configurable commission with overrides | done |
| Ticket upload, barcode extraction, duplicate detection | done |
| Moderation queue | done |
| Checkout integration and escrow ledger | done |
| Escrow release job, day after the event | done |
| Disputes with transfer freeze | done |
| Signed-URL ticket delivery | done |
| Seller dashboard and buyer ticket access | done |
| Event browsing, search, geoblocking | done |
| Transactional email | done |
| DAC7 counters, TIN collection, CSV export | done |
Not built, and deliberately so: an integration with a primary ticketing platform's resale API. See Barcode reissue.
Requirements
- WordPress 6.4+
- WooCommerce 8.0+
- PHP 8.1+
- A Stripe account with Connect enabled
Setup
Install and activate, then set the Stripe secret key. Prefer the constant so the key never lands in the database:
// wp-config.php
define( 'TM_STRIPE_SECRET_KEY', 'sk_test_...' );
Activation creates three tables, provisions protected ticket storage, adds the tm_moderate_listings, tm_manage_events and tm_view_payouts capabilities to administrators, and schedules the daily tm_release_escrow job.
Then, under Ticket Marketplace:
- Settings — set the commission rate. Until an admin saves one, the fallback is 10%.
- Events — add events with a country, a date, and face-value tiers. Sellers can only list against these.
Schema changes are applied by bumping Support\Schema::VERSION; Migrator::maybe_upgrade() runs dbDelta on the next admin request.
Trying it out
The whole flow, end to end. Replace the host with your own.
1. Let someone sell
Sellers cannot list until they can be paid — a ticket sold by someone you cannot pay becomes a refund. With a Stripe test key set, a seller does this themselves at My Account → Sell a ticket, which sends them through Stripe's hosted onboarding.
Without Stripe configured, mark an account as verified locally:
./tests/wp.sh eval-file tools/enable-seller.php <username-or-email>
That is a testing shortcut. It fabricates a Stripe account ID, so payouts will fail against the real API — which is correct, and what the failed payout state is for.
2. Create an event
Ticket Marketplace → Add Event. Sellers pick from this catalogue; they cannot invent events, because a face-value cap is unenforceable if the seller also declares the face value.
Fill in:
- Starts at (UTC) — required, and it drives the payout date.
- Country — this is what selects the compliance rules. The field tells you the cap as you choose.
- Face-value tiers — at least one, with the price printed on the real ticket. An event with no tiers is not offered to sellers at all.
3. List a ticket
As the seller, My Account → Sell a ticket: pick the event and tier, set a price, add block/row/seat, attach the PDF.
Try asking above face value on a capped market — it is refused with the maximum stated. The listing lands as Awaiting review and no product exists yet, so it cannot be bought.
4. Approve it
Ticket Marketplace → Moderation (the menu shows a count). Open the ticket file and check it against what the seller claimed: right event, right tier, face value matching the print.
Approving creates the hidden WooCommerce product and puts it on sale. Rejecting requires a reason, emails it to the seller, and deletes the file.
From the command line:
wp tm moderate approve 42
wp tm moderate reject 42 --reason="Face value does not match the ticket"
5. Buy it
The event page lists its tickets, cheapest first, with a Buy button. Or put a searchable index on any page:
[tm_events limit="20"]
[tm_listings event_id="123"]
Check out as normal. On payment the stock is claimed, the payout is recorded, and the buyer sees Your tickets with a download link on the order page.
6. Watch the money
Ticket Marketplace → Payouts shows the ledger. A fresh sale sits pending with a due date of one day after the event ends.
wp tm escrow-release --dry-run # what is due
wp tm escrow-release # release it
wp tm payouts --status=pending
To see a release without waiting, set an event's date to the past and run the job. Nothing releases early: a payout with no event date is never due, which is deliberate — a missing date delays a payout rather than paying before the ticket was used.
7. Disputes
A buyer clicks Report a problem on their order. That freezes the payout. In Payouts → Disputed you either refund the buyer or reject the dispute and let payment resume. Because the money has not moved yet, a refund costs only the processing fee.
Before taking real money
Three non-engineering prerequisites gate launch:
- Declare the business model to Stripe. Ticket resale is restricted-but-allowed. Expect a rolling reserve of 5–10% held 30–180 days. Being shut off after launch is the failure mode to avoid.
- Review the market rule table. It ships with a small researched set; everything else falls back to a face-value cap.
- Write the buyer guarantee, seller agreement and terms. The guarantee is a product feature with a real cost line.
How money moves
Stripe Connect separate charges and transfers. The buyer's payment lands in the platform balance at checkout; no transfer exists until the release job creates one, a day after the event ends.
checkout ──▶ payout row (pending) ──▶ event ends ──▶ +24h ──▶ Transfer to seller
│
├── dispute opened ──▶ frozen, never releases
├── dispute upheld ──▶ refunded, net zeroed
└── order refunded ──▶ refunded, stock returned
Two alternatives were rejected:
delay_dayson the connected account caps at 90 days, and festival tickets sell further out than that.- Stripe's
funds_segregationsounds purpose-built but is preview-only, available in ten countries, and explicitly incompatible with cross-border payouts, which a worldwide marketplace needs. Revisit if that changes.
Holding funds is the load-bearing decision. A pre-release refund costs only the processing fee, so buyer protection is cheap and every other choice leans on it.
Run the job manually with wp tm escrow-release, or --dry-run to see what is due.
The limit worth understanding
The reason TicketSwap and Tixel are trusted is that a sold ticket's original barcode is invalidated and a fresh one issued. That requires an integration with whoever sold the ticket first, and those APIs are gated to approved partners.
This plugin does file handoff instead, which has no cryptographic guarantee: a seller can upload the same PDF twice. Four things compensate, and none of them is a substitute:
- Moderation. Every listing is reviewed before it goes on sale. No product exists until approval, so an unreviewed ticket cannot be bought even via a direct add-to-cart URL.
- Duplicate detection. A barcode hash with a unique index on
(event_id, barcode_hash), plus a file-content hash for tickets whose barcode will not extract. - Seller verification. Stripe Connect Express KYC before a first listing goes live.
- Post-event payouts. An invalid ticket surfaces at the door, while the platform still holds the money.
Delivery\BarcodeExtractor reads PDF content streams and returns null rather than guessing; photographs and screenshots always return null, because decoding a QR from an image needs a computer-vision dependency and pretending otherwise would give false confidence. Nulls go to manual review, and the moderation queue flags them so the reviewer knows automatic detection did not apply.
Escrow\EscrowManager talks to a Stripe\PayoutGateway interface, so a real barcode-reissue integration can be added without touching the escrow logic.
Commission
Set by an admin, never hardcoded. Ticket Marketplace → Settings holds the site-wide default and shows a worked example of what a seller receives at three sale prices, because a percentage plus a fixed fee is easy to misconfigure by a factor of ten.
Overrides, most specific first:
- Per seller — user profile screen, admin-only. For negotiated rates.
- Per event — event editor. For promoter partnerships or zero-fee events.
- Site default — settings page.
A seller override beats an event override, because it usually reflects a contract that a marketing promotion on one event should not silently overwrite. Use the tm_commission_rate filter for the opposite precedence.
Sellers cannot set their own rate; the profile fields render only for manage_options.
Three implementation points that matter:
Rates are basis points, not float percentages. 8.25% is 825, exact through every calculation. 0.0825 is not.
The percentage floors and net is derived by subtraction, so commission plus net always equals gross exactly. Rounding both halves independently is how marketplaces lose a cent on every payout.
The rate is frozen onto the payout row at the moment of sale. Recomputing at payout time would let an admin editing the default change what a seller is owed for a sale that already happened.
Null means inherit; zero means zero. A negotiated 0% rate is a real arrangement, so it cannot collapse into "unset".
Compliance
Rules resolve against the event's country, not the seller's or buyer's: a London show is governed by UK law whoever sells the ticket.
The default for an unresearched market is a face-value cap, not free pricing. A worldwide marketplace will meet countries nobody has reviewed, and the safe failure mode is a cap.
| Market | Rule | Why |
|---|---|---|
| BE | blocked | Bans habitual resale and "provision of means" for it, which plausibly reaches platforms. Before the EU Court of Justice. |
| GB | face value, seat disclosure, 4 listings/event | Draft Ticket Tout Ban Bill; platforms must enforce actively, CMA penalties to 10% of global turnover. |
| IE | face value | Sale of Tickets Act 2021. |
| NL | face value | Permissive today; Digital Fairness Act expected to address resale. |
| AU | 110% of face value, seat disclosure | Several states cap at 110%. |
| US | uncapped | No federal cap. BOTS Act targets automated buying, not price. |
| default | face value | Unreviewed market. |
Both tm_market_rules_config (the whole table) and tm_market_rules (a single resolved market) are filterable.
These positions move. The UK bill and the EU Digital Fairness Act were both in flight when this was written. Re-review on a schedule and keep the note field populated so the next reviewer knows what each rule was reacting to.
Enforcement happens at four points: listing creation, listing edit, moderator approval, and again at checkout. A festival listing can sit live for months while the rule table changes underneath it, so nothing is trusted once written. Blocked markets are hidden from event browsing and from the seller's event picker, rather than shown and refused later.
DAC7: EU platforms must report sellers passing 30 transactions or €2,000 in a calendar year. Counters increment at each sale, sellers are prompted for a tax ID on their earnings page once reportable, and Tax Reporting exports the rows. Counters run from day one because a year's transaction counts cannot be reconstructed from data nobody recorded.
Ticket file custody
Ticket files are never WooCommerce downloadables. Woo grants download access on order status alone; a ticket also requires that the listing cleared moderation.
Files live outside the web root — by default in a randomly-named directory one level above the WordPress install. This is not a stylistic preference. The first implementation kept them under uploads/ behind an .htaccess deny, and testing it over HTTP on nginx showed every ticket PDF was downloadable by URL with no login: .htaccess is Apache and LiteSpeed only, web.config is IIS only, and nginx honours neither. Nothing but being outside the served tree protects these files.
Override the location explicitly if you want to:
define( 'TM_TICKET_STORAGE_DIR', '/var/lib/ticket-marketplace/tickets' );
On a host with no writable directory above the WordPress root, storage falls back into uploads/ and an admin notice says so, because on nginx that state is not safe. ProtectedStorage::is_web_accessible() reports it.
Files are stored with random names, an extension taken from an allowlist rather than from the upload, and mode 0600. ProtectedStorage::resolve() refuses any path escaping the storage root, including via symlink.
Delivery goes through Delivery\DeliveryHandler: an HMAC-signed URL, valid 15 minutes, compared with hash_equals. Entitlement is decided per request — a buyer needs a paid order for that listing, a seller can always fetch their own upload, a moderator can see anything pending. All of that was verified over real HTTP: unauthenticated, tampered-signature, expired, wrong-context and no-entitlement requests each return 403.
Rejecting a listing deletes the file: a rejected ticket is either invalid or fraudulent, and holding somebody's barcode after refusing to sell it serves no purpose.
Architecture notes
Listings are a custom table, not a CPT. Transactional rows filtered hard on event, status and price; postmeta handles that badly. Money is stored in integer minor units so no float rounding reaches an amount a seller is paid.
The WooCommerce product is a one-way projection. ProductMirror writes listing state onto a hidden product so checkout works. Nothing writes back. Product stock is mirrored for early feedback, but the authority is ListingRepository::claim_quantity(), a single conditional UPDATE — two simultaneous checkouts can both pass a Woo stock check and oversell a ticket that exists exactly once.
Payout creation is idempotent, enforced by a unique index on order_item_id. WooCommerce fires status transitions more than once, and paying a seller twice is not recoverable by a retry.
Transfers use a derived idempotency key (tm_payout_{id}_{net}), so a job retried after a timeout reuses the same key and Stripe returns the original transfer rather than creating a second one. Failures return the row to pending for up to three attempts, then stop for an operator.
The Stripe client is a wp_remote_post wrapper, not the official SDK. Two plugins bundling different stripe-php majors is a well-known way to break a site, and the surface needed here is small.
WP-CLI
wp tm escrow-release [--dry-run] # release everything due
wp tm payouts [--status=] [--seller=] # inspect the ledger
wp tm moderate approve <id> # approve a listing
wp tm moderate reject <id> --reason= # reject, with a reason the seller sees
Development environment
Developed against a LocalWP site with the project symlinked into place:
ln -s /path/to/ticket-marketplace-wp \
~/"Local Sites/tickets-marketplace-wp/app/public/wp-content/plugins/ticket-marketplace"
One source of truth, no copying. Use tests/wp.sh to drive wp-cli; it handles two things a bare CLI invocation gets wrong:
- The MySQL socket.
wp-config.phpsetsDB_HOST=localhost, so PHP looks for a socket at its compiled-in default path. Local supplies the real path through its PHP-FPM ini, which a CLI run never loads, so WordPress cannot connect until it is passed explicitly. Confusingly, rawmysqlworks while WordPress reports "error establishing a database connection". The script finds it from the runningmysqld. - PHP 8.5 deprecations. Local's bundled
wp-cli.pharis not 8.5-clean and floods stderr. Suppressed; genuine errors are not.
The site must be started in Local first.
Tests
Three suites. Run all of them.
php tests/logic-check.php # 61 checks
./tests/wp.sh eval-file wp-content/plugins/ticket-marketplace/tests/integration-check.php # 145 checks
./tests/wp.sh eval-file wp-content/plugins/ticket-marketplace/tests/escrow-check.php # 175 checks
If PHP comes from a half-installed Herd, add -d auto_prepend_file= to bypass its broken loader.
The two wp-cli suites create their own fixtures and remove them, so they are safe to re-run. They write to the live database of whatever site they are pointed at — development sites only.
logic-check.php— pure logic, no WordPress. Price caps, commission splits, money parsing,$wpdbformat alignment.integration-check.php— repositories, event meta, oversell protection, product mirroring, commission precedence, storage guards, and every admin and frontend screen rendering.escrow-check.php— the transaction path end to end against a fakePayoutGateway: real WooCommerce orders driven through their status transitions, escrow release, retries, disputes, refunds, barcode extraction, listing lifecycle, DAC7.
What the tests are actually for
Each suite exists to catch a specific class of mistake rather than to inflate a count.
Price ceilings sweep 2000 face values asserting the result never exceeds the true statutory maximum. Landing a cent low is acceptable; a cent over a cap is a breach, so the ceiling floors rather than rounds.
Commission splits sweep 189 rate and amount combinations asserting commission + net == gross with neither side negative.
$wpdb format alignment exists because $wpdb applies the format array positionally against the data array. Building formats in canonical column order misaligns them the moment a caller appends a key out of order — which had stripe_account_id formatted as %d and written as 0. Derive formats from the data array's own key order.
Oversell checks cover unsigned integer underflow. quantity_sold is SMALLINT UNSIGNED, and MySQL raises an out-of-range error for quantity_sold - n when the result would go negative, aborting the whole UPDATE before a GREATEST(0, …) wrapper can clamp it. Releasing more stock than was claimed therefore left the stock claimed and the ticket permanently unbuyable. Stock arithmetic now uses - LEAST(quantity_sold, n) and comparisons rather than subtractions.
The escrow suite drives real order status transitions rather than calling the escrow API directly. A correct EscrowManager wired to the wrong hook would pass every unit-style test and pay nobody.
A fake PayoutGateway makes the release path testable without network access. It records transfers, so the tests can assert the amount is net rather than gross, that the idempotency key survives a retry, that a released payout is never sent twice, and that a zero-net payout is settled rather than transferred.
Render checks exist because nothing else executes those code paths; a fatal in a screen would surface only when somebody opened it.
Storage-location checks assert that ticket files are not under ABSPATH and not under uploads/. That regression is how the files became publicly downloadable once already.
What the suites cannot cover
Three paths need a real HTTP request and are not reachable from wp-cli. They were verified once by hand with curl against the LocalWP site — a genuine multipart upload of a PDF, then the download handler — but there is no automated regression for them:
TicketFileStore::accept(), becauseis_uploaded_file()is false for anything not POSTed.ListingService::create(), which depends on it.DeliveryHandler::maybe_serve(), which reads request query args and callsexit.
Re-check these by hand after touching upload or delivery code. A browser-driven or HTTP-level suite would close the gap properly.
No live Stripe call has ever been made. Every transfer in the test suite goes through a fake gateway. Before launch, run one real low-value transaction end to end in live mode: test mode does not surface reserve behaviour, cross-border payout restrictions, or Stripe's risk review, which are the things most likely to break this business.
This file is developer documentation. readme.txt is the WordPress.org plugin-directory readme, in the format wp.org parses — that is the one that drives the plugin listing page.
Releases
1 release. Each count is every asset in that release; expand a row for the breakdown.