WP Manifestindependent plugin directory
manifest / ecommerce / redeemic

Redeemic

WordPress plugin that redeems AppSumo codes as Lemon Squeezy licenses — validates the code, mints a single-use 100% variant-locked discount, and returns a $0 checkout that mints the license key.

by Formgic · github.com/d-raj-grg/redeemic · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/d-raj-grg/redeemic/archive/refs/heads/main.zip

Readme

Redeemic

Turns AppSumo redemption codes into Lemon Squeezy licenses.

A buyer enters their AppSumo code on a page carrying [redeemic]. Redeemic validates it, mints a single-use 100% discount locked to one Lemon Squeezy variant, and hands back a $0 checkout that asks for no card. Lemon Squeezy issues the license key when that checkout completes, and a webhook writes the order and key back onto the code row — so you can see which codes converted, which stalled, and which failed.

Requires: WordPress with PHP 8.0+, a Lemon Squeezy store, Node 20.10+ and npm 10.2.3+ to build the admin bundle.

Install target: formgic.com (the public marketing site, where the redeem page lives).

Contents

Why it works this way

AppSumo hands buyers redemption codes. Lemon Squeezy has no concept of one. The only way LS expresses "free" is a 100%-off discount applied at checkout, and LS has no endpoint that mints a license key directly — the license-keys API is read/update only. So the chain is:

AppSumo code  ->  validate  ->  POST /v1/discounts   (100%, 1 redemption, variant-locked, expiring)
                            ->  POST /v1/checkouts   (discount pre-applied, email prefilled, custom_data attached)
                            ->  buyer completes a $0 order
                            ->  LS mints the license key
                            ->  webhook writes the order + key back onto the code row

One code = one license. A buyer who stacks three codes gets three one-site lifetime keys and uses a different key on each site. Formgic Pro needs no changes to support this — Gate::tier() is binary and every tier grants the same features. Only a deal where tiers differ in features rather than seat count would force plugin work.

Design decisions worth knowing

Checkouts are created through the API, not by appending ?checkout[discount_code]= to a store URL. The hand-built link shows the discount in the address bar, carries no expiry, prefills nothing, and gives the webhook no way to link the resulting order back to an AppSumo code. POST /v1/checkouts gives all four.

Only the hash of each code is stored. Plaintext exists in the CSV handed to AppSumo and nowhere else. A stolen database is worth nothing, because redeeming needs the code itself and there is no way back from an HMAC. The trade: the CSV downloads exactly once and cannot be regenerated.

The row is claimed before Lemon Squeezy is called. The status check lives in the WHERE clause of the claiming UPDATE, so two simultaneous submits cannot both win — the loser sees zero affected rows. The consequence is that a failed LS call leaves a claimed row, which the sweeper releases within ten minutes. A customer briefly unable to retry beats a customer whose only code was silently eaten by a network blip.

Every discount is variant-locked, single-redemption, and expiring. Without is_limited_to_products the code buys anything in the store; without max_redemptions a leaked code is an unlimited free-license faucet; without expires_at it stays one forever.

Admin UI

The five admin screens are a React SPA in src/admin/, built with @wordpress/scripts (webpack) — the same toolchain as formgic-pro, so the two look like one product. React and ReactDOM come from WordPress core rather than the bundle.

npm install
npm run build     # -> assets/build/admin.js, admin.css, admin.asset.php
npm start         # watch mode

The bundle is not committed while the brand is still moving — a stale build that references renamed globals fails silently, which is worse than no build at all. Commit assets/build/ once the naming settles, so the plugin ships without needing npm on the target site.

If admin.asset.php is missing, the admin screens print a notice telling you to run the build.

Tailwind preflight is disabled in tailwind.config.js and re-implemented, scoped, in src/admin/index.css:

:where(#redeemic-root, [data-radix-popper-content-wrapper], [data-redeemic-dialog])

Both halves matter. Unscoped preflight strips the padding off wp-admin's #adminmenu list and shifts the sidebar icons. But an ID selector like #redeemic-root button { padding: 0 } would outrank every Tailwind utility and silently break .px-3 throughout — hence :where(), which contributes zero specificity. The Radix and dialog selectors are there because those components portal to <body>, outside the root div.

Admin data comes from Redeemic_Admin_Rest (includes/class-admin-rest.php), capability-checked on manage_options and nonce-protected. It is deliberately separate from Redeemic_Rest, which serves the public redemption route where the AppSumo code is the credential — the two must never share a permission model.

Setup

0. Verify first (LS test mode, before anything else)

Each of these invalidates the design if it fails:

  1. A checkout with a 100% discount completes with no payment method. AppSumo will reject a deal that asks for a card.
  2. Each mapped variant is a one-time payment, not a subscription. A 100% discount on a subscription renews at full price in year two and breaks the lifetime promise. The Tiers screen checks this for you.
  3. POST /v1/discounts accepts amount: 100, amount_type: "percent".
  4. A license key is issued on the $0 order and meta.custom_data survives into the webhook payload.

1. Configure

Redeemic -> Settings, or in wp-config.php (constants always win and render the fields read-only):

define( 'REDEEMIC_LS_API_KEY',        '<Lemon Squeezy API key>' );
define( 'REDEEMIC_LS_STORE_ID',       '411880' );
define( 'REDEEMIC_LS_WEBHOOK_SECRET', '<LS webhook signing secret>' );
define( 'REDEEMIC_ENC_KEY',           '<32+ random chars>' );

Behind Cloudflare or a load balancer, and only then, name the header the proxy sets — otherwise rate limiting uses REMOTE_ADDR, because reading X-Forwarded-For from untrusted traffic hands an attacker a fresh bucket per request:

define( 'REDEEMIC_TRUSTED_IP_HEADER', 'HTTP_CF_CONNECTING_IP' );

Leave Test mode on until the whole flow is proven end to end.

Discount percentage defaults to 100 and 100 is the only value a real deal should use — a redemption is supposed to be free. The setting exists because Lemon Squeezy cannot always complete a genuine $0 checkout in test mode, and a 99% discount lets you drive the whole pipeline — discount, checkout, order, webhook, license key — with a real payment. It is clamped to 1–100, so a stray 0 or 500 cannot produce a discount LS rejects. Put it back to 100 before the deal goes live.

2. Map tiers

Redeemic -> Tiers. One row per AppSumo tier. Hit Validate on each: it names the variant and warns loudly if it is a subscription.

3. Point the webhook at the plugin

In Lemon Squeezy, create a webhook for order_created, license_key_created, and order_refunded:

https://formgic.com/wp-json/redeemic/v1/webhook/ls

4. Build the pages

  • Redeem page: [redeemic]
  • Success page: [redeemic_success] — set its ID in Settings, since LS redirects there after purchase.

5. Generate codes

Redeemic -> Batches. Pick a tier and a quantity (AppSumo caps at 10,000 per deal; generate more than you expect to sell). The codes come back in the response and the browser builds the CSV locally — no header row, one column, the format AppSumo wants.

Keep that file. Only a hash is stored, so it cannot be recovered. If you close the dialog without downloading, the batch is dead; generate a new one.

Expired checkouts re-issue themselves

A checkout link lives 14 days by default. AppSumo buyers routinely sit on a code for longer. When the same buyer returns after their link has died, Redeemic deletes the stale Lemon Squeezy discount and mints a fresh discount and checkout rather than telling them the code is spent. Gated three ways: the code must be in status redeemed, the submitted email must match the one on the row, and a converted code is never re-issued — that buyer already has their key and a second checkout would hand them a license they do not need.

Support has the same power: the resend button re-issues instead of failing when the link has lapsed.

Activity and the silent-failure hole

When Lemon Squeezy refuses to create a discount or a checkout, the code is released back to available and redeemer_email, claimed_at and redeemed_at are cleared. The row then looks identical to a code nobody has ever touched. So an expired API key, a rate limit, or a wrong store ID takes down every redemption while the Dashboard, the Codes screen and your inbox all look completely normal.

The redeemic_events table has always recorded those failures. Nothing read it until the Activity tab existed, which meant the only way to discover the problem was querying the table by hand.

  • Activity lists every event newest-first, with a Failures only toggle. The Lemon Squeezy error text is printed inline, not behind an expander.
  • Dashboard raises a destructive alert whenever anything failed in the last 24 hours, linking straight to the filtered feed.
  • Any code row opens a drawer with its full timeline, every timestamp, and the resend / revoke actions.

Retention: events are pruned after 180 days by the daily redeemic_prune_events job, batched at 5,000 rows a run so a backlog cannot time the cron out. Only the timeline goes — ls_order_id and ls_license_key live permanently on the codes row, so which license a code became is still answerable in year two.

Endpoints

Route Auth Purpose
POST /wp-json/redeemic/v1/redeem Public; the AppSumo code is the credential Validate a code, return a checkout URL
POST /wp-json/redeemic/v1/webhook/ls HMAC-SHA256 over the raw body, X-Signature Record orders, keys, refunds

The redeem route sits behind a honeypot, optional invisible reCAPTCHA v3, and two ceilings: 5 failed attempts per IP per 15 minutes, and 40 total. Only failures count toward the tight one, because a buyer who stacked eight codes redeems eight times in a row and locking them out on the sixth is indistinguishable from a broken deal. Guessing is what needs throttling, and a guess is by definition a failure. Everything is checked before the database lookup, so a script cannot use the route as an oracle. Every response carries Cache-Control: private, no-store, because a CDN that caches one buyer's checkout link and serves it to the next visitor gives away a license.

Code lifecycle

available -> claiming -> redeemed -> converted
     ^           |                       |
     |           v (LS call failed,      v (LS refund)
     +-------- swept)                 refunded

available -> revoked   (manual, available codes only)

converted is set from the webhook, which is the only thing that proves a buyer actually finished. Everything short of it is a redemption that may still lapse.

Scheduled jobs

Hook Cadence Job
redeemic_sweep_claims 10 min Release stranded claims, delete orphaned discounts
redeemic_stock_check daily Warn when a tier drops below the threshold (deduped 24h)
redeemic_nudge daily Chase redemptions that never reached checkout
redeemic_reconcile daily Backfill dropped order and license-key webhooks

Selective reset

Redeemic -> Settings -> Reset wipes chosen slices of the plugin's state without uninstalling — the screen an operator needs after a test run, when the database is full of fake redemptions and the real deal is about to start.

Six scopes, each independently selectable: codes (codes and batches), activity (the event log), tiers, lemonsqueezy (API key, store ID), webhook (signing secret), alerts (low-stock dedupe state).

The dialog previews counts before it deletes — how many codes exist, how many of those are redeemed, converted or refunded — rather than asking the operator to take "this cannot be undone" on faith. Arming it means typing RESET, checked server-side as well as in the dialog, because a second "are you sure?" button is muscle memory by the time anyone reaches it and typing is not.

The code pepper is never deleted here, which is the one deliberate divergence from uninstall.php. Codes are stored only as HMACs keyed by that option, so dropping it makes every code in the wild permanently unredeemable — and, the part that catches people, a database restore does not bring them back, because the pepper that produced the hashes is gone. Deleting the code rows already makes those codes unusable, so destroying the pepper buys nothing and forecloses recovery. Uninstall is the only place that may take it.

What was wiped and by whom is recorded in redeemic_last_reset, since the audit log itself may be one of the things that went.

Read the full README on GitHub →