WP Manifestindependent plugin directory
manifest / ecommerce / ticket-marketplace-crypto

Ticket Marketplace (Crypto)

WIP: peer-to-peer ticket resale marketplace for WordPress, settled in crypto. Escrow-backed payouts, per-market compliance, moderation queue. No live provider integration yet.

★ 0stars
Pluginlatest · never

Install

Download ticket-marketplace-wp.zip From the Plugin release on GitHub, Aug 2, 2026

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-crypto/releases/download/Plugin/ticket-marketplace-wp.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

⚠️ Under active development — not production ready

No cryptocurrency has ever moved through this code. Every payout and refund in the test suite runs against a fake gateway. It has never been pointed at a real NOWPayments account, on mainnet or in sandbox.

The logic is thoroughly tested — 498 automated checks — but tests prove the code does what it was written to do, not that the provider behaves as its documentation says. Specifically unproven:

  • Whether custody withdrawals work with wallet-whitelisting disabled, which this design requires because sellers supply arbitrary addresses.
  • Whether the locally generated TOTP is accepted by the provider, and whether it keeps being accepted unattended at 3am when the release cron runs.
  • Real network minimums and custody limits, which only appear under load.
  • The buyer checkout half. The NOWPayments gateway plugin is a separate dependency and has not been run with live credentials.

Do not put real money through this without completing the sandbox pass described under Tests, and read Three risks this design does not solve before deciding whether the trade-offs are acceptable to you.

A peer-to-peer ticket resale marketplace for WordPress, settled in cryptocurrency. Fans list tickets they cannot use; buyers pay in stablecoin; sellers are paid to their own wallet the day after the event.

This is a fork of ticket-marketplace-wp with one substitution: the payment rails. Everything about what is traded is identical — the same PDF tickets, the same moderation queue, the same barcode dedup, the same signed-URL delivery, the same per-market price caps. Only the money moves differently.

The fork was cheap because the original was built for it. EscrowManager never talked to Stripe; it talked to a one-method PayoutGateway interface. The escrow state machine, release cron, retry logic, dispute freeze and the entire test suite were already provider-agnostic.

It uses its own namespace (TicketMarketplaceCrypto), table prefix (tmc_), post type (tmc_event) and capabilities, so both plugins can run on the same site at once without colliding.

What differs from the card version

Card version This version
Buyer pays Stripe NOWPayments gateway plugin, 300+ assets
Seller identity Stripe Connect KYC A wallet address. No identity.
Payout rail Stripe transfer NOWPayments Mass Payouts
Payout verification none needed TOTP generated locally per payout
Denomination fiat throughout priced in fiat, settled in stablecoin
Chargebacks a real risk none
Refunds reversal a second outbound payment
Minimum payout none per-network, enforced before release

Why NOWPayments

Chosen mostly by elimination. The requirement that rules out nearly every WooCommerce crypto plugin is not checkout — it is payouts. This is a marketplace, so money must reach third-party sellers, and BTCPay, CoinGate, Triple-A and Aurpay are all inbound-only. NOWPayments is the one option covering both legs.

It also fits the other constraints directly: price_currency takes fiat while pay_currency takes the token, which is exactly "priced in fiat, settled in stablecoin", and its asset coverage gives multi-chain support with no per-chain work here.

Checkout is their plugin, not code in this repo. It fires WooCommerce's normal payment hooks, which is all Checkout\CheckoutIntegration needs. This repo implements only the outbound half.

Operational prerequisites

Two of these deliberately weaken provider-side security, and both are unavoidable for an automated marketplace:

  • Custody mode on. Payments must accumulate in the custody balance, not auto-forward to an external wallet. Escrow needs funds to sit for weeks or months; auto-forwarding defeats it.
  • Auto-convert incoming payments to a stablecoin. Funds are held from sale until a day after the event. A buyer paying in a volatile asset for a fiat-priced ticket months out would leave the seller's net drifting from what they were promised.
  • Wallet whitelisting off. Withdrawals are normally restricted to whitelisted addresses. Sellers supply arbitrary ones, so this cannot stay on. Keep IP whitelisting on to compensate.
  • Payout 2FA via TOTP. Payouts need a 2FA code within an hour or they are auto-rejected, and a nightly cron cannot read an email. Put the base32 secret in TMC_NOWPAYMENTS_TOTP_SECRET. The alternative — asking the provider to disable payout 2FA — leaves the API key as the only thing guarding the whole custody balance.
// wp-config.php — preferred over storing these in the database
define( 'TMC_NOWPAYMENTS_API_KEY', '...' );
define( 'TMC_NOWPAYMENTS_EMAIL', '...' );
define( 'TMC_NOWPAYMENTS_PASSWORD', '...' );
define( 'TMC_NOWPAYMENTS_TOTP_SECRET', 'BASE32SECRET' );
define( 'TMC_TICKET_STORAGE_DIR', '/path/outside/webroot' );

Three risks this design does not solve

No KYC changes the fraud model, not just the signup form. Stripe Connect was doing identity verification for free, and it was why a banned seller could not trivially return. A wallet-only seller is back in seconds with a new address. Moderation and post-event payouts now carry the entire load. SellerRepository::find_by_payout_address() returns every seller sharing an address precisely because that is a ban-evasion signal worth surfacing.

DAC7 becomes unsatisfiable, not merely harder. EU platforms must report sellers passing 30 transactions or €2,000 a year, with a tax ID. You cannot report someone you cannot identify. The counters, the prompt and the export all still work, but a seller who declines is now anonymous rather than uncooperative. Resolve with counsel; the likely answer is demanding identity at the threshold, which needs_dac7_tin() already models.

A mistyped address destroys money. No format check can tell a valid stranger's address from the seller's own, and an on-chain payment cannot be recalled. WalletOnboarding requires the address twice and says so bluntly. That is the ceiling of what code can do here.

Requirements

  • WordPress 6.4+
  • WooCommerce 8.0+
  • PHP 8.1+
  • A NOWPayments account with custody enabled
  • The NOWPayments WooCommerce plugin, for buyer checkout

Setup

Install and activate, then add the provider credentials shown above and set the commission rate under Ticket Marketplace → Settings.

Activation creates three tables, provisions protected ticket storage, adds the tmc_moderate_listings, tmc_manage_events and tmc_view_payouts capabilities to administrators, and schedules the daily tmc_release_escrow job.

Then, under Ticket Marketplace:

  1. Settings — set the commission rate. Until an admin saves one, the fallback is 10%.
  2. 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. A seller does this themselves at My Account → Payout wallet: pick an asset, paste an address, retype it.

To skip that during local testing:

./tests/wp.sh eval-file tools/enable-seller.php <username-or-email>

That is a testing shortcut. It writes a placeholder Solana address, so real payouts would go somewhere you do not control — never run it against anything but a development site.

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:

[tmc_events limit="20"]
[tmc_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:

  1. Review the market rule table. It ships with a small researched set; everything else falls back to a face-value cap.
  2. Write the buyer guarantee, seller agreement and terms. The guarantee is a product feature with a real cost line.

How money moves

The buyer's payment settles into the provider's custody balance at checkout; nothing leaves until the release job sends it, 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:

  • Paying the seller at checkout removes buyer protection entirely, and on-chain there is no chargeback to fall back on.
  • Auto-forwarding payments to an external wallet, which is the provider's default, would empty the balance immediately and leave nothing to hold.

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:

  1. 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.
  2. 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.
  3. Seller verification. None — see the risks above. This is the weakest link in this fork.
  4. 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 Payout\PayoutGateway interface, so a real barcode-reissue integration, or a different payout provider, 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:

  1. Per seller — user profile screen, admin-only. For negotiated rates.
  2. Per event — event editor. For promoter partnerships or zero-fee events.
  3. 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 tmc_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 tmc_market_rules_config (the whole table) and tmc_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( 'TMC_TICKET_STORAGE_DIR', '/var/lib/ticket-marketplace-crypto/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 (tmc_payout_{id}_{net}), so a job retried after a timeout reuses the same key. On-chain this matters more than it did with cards: a duplicate payment cannot be recalled. A created-but-unverified payout expires within the hour, so a failure between the two provider calls moves nothing. Failures return the row to pending for up to three attempts, then stop for an operator.

The provider client is a wp_remote_post wrapper, not an SDK. Two plugins bundling different versions of a vendor library is a well-known way to break a WordPress site, and the surface needed here is small.

Fiat-to-stablecoin conversion lives in EscrowManager, not the client. It is a money-policy decision, not a transport concern, and AmountConverter refuses an unconvertible pair rather than guessing a rate.

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-crypto-wp \
  ~/"Local Sites/tickets-marketplace-wp/app/public/wp-content/plugins/ticket-marketplace-crypto"

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.php sets DB_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, raw mysql works while WordPress reports "error establishing a database connection". The script finds it from the running mysqld.
  • PHP 8.5 deprecations. Local's bundled wp-cli.phar is not 8.5-clean and floods stderr. Suppressed; genuine errors are not.

The site must be started in Local first.

Tests

Four suites. Run all of them.

php tests/logic-check.php                                                                            #  89 checks
./tests/wp.sh eval-file wp-content/plugins/ticket-marketplace-crypto/tests/integration-check.php     # 154 checks
./tests/wp.sh eval-file wp-content/plugins/ticket-marketplace-crypto/tests/escrow-check.php          # 207 checks
./tests/wp.sh eval-file wp-content/plugins/ticket-marketplace-crypto/tests/nowpayments-check.php     #  48 checks

nowpayments-check.php drives the real payout client through WordPress's pre_http_request filter, so request building, JSON, TOTP and error mapping all execute against canned responses without a network or an account. Its load-bearing assertion is that the client sends the converted asset amount rather than the fiat minor units — sending 4500 where 45 was meant would pay a seller a hundred times what they are owed.

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, $wpdb format 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 fake PayoutGateway: 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.

Address validation checks cover cross-chain confusion specifically: a Solana address offered for a Base payout, an EVM address for Tron, base58 containing a 0. Paying to a valid-looking address on the wrong chain loses the money as surely as a typo.

TOTP checks use the RFC 6238 published vectors. If code generation drifts, payouts fail verification silently at 3am and nobody gets paid — a failure with no user-visible symptom until sellers complain.

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(), because is_uploaded_file() is false for anything not POSTed.
  • ListingService::create(), which depends on it.
  • DeliveryHandler::maybe_serve(), which reads request query args and calls exit.

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 provider call has ever been made. Every transfer in the test suite goes through a fake gateway. Before launch, run one real low-value payout end to end: a sandbox will not surface custody limits, network minimums, or whether the TOTP automation actually holds up unattended at 3am — which is when the release cron runs.


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.

Tag
Published
Assets
Downloads
Plugin latest
Aug 2, 2026 2mo ago
ticket-marketplace-wp.zip
0